@r1z4x/tezgah 1.5.0 → 1.5.1

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,54 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.5.1] - 2026-10-09
10
+
11
+ ### Fixed
12
+
13
+ - **Subagents load in the first session.** Claude and Codex missed every role a
14
+ session wrote into a new repo until the next session. Codex also ignored
15
+ `.codex/agents` in an untrusted repo. The install now writes the roles to
16
+ `~/.claude/agents` and `$CODEX_HOME/agents`, as it already did for omp. A
17
+ session removes the copies an older release left in a repo's `.claude/agents`
18
+ and `.codex/agents`. Cursor read 9 of 10 role descriptions as ">" and got a
19
+ model id it does not list. It now gets its own `.cursor/agents` with a
20
+ one-line description and `model: inherit`.
21
+
22
+ - **`tezgah-route` names the specialists.** It could only name a tier. The
23
+ five specialists ran 0 times in 121 delegations. The generic task agent ran 86.
24
+ The judge now also answers whether a brief is a specialist's whole
25
+ job, and the route names that agent. `--phase review` and `--phase research`
26
+ name the reviewer and the researcher. A security brief that only reviews
27
+ goes to `tezgah-security`. The routing and fan-out text say to spawn the
28
+ named agent, never the generic one.
29
+ - **Jev through OpenRouter asks a model OpenRouter has.** The carrier sent
30
+ `typesafe/jev-latest`, which OpenRouter answers with 400 (no such model). It
31
+ now sends the bare `jev-latest`, which OpenRouter maps itself.
32
+ - **The Workers AI model id picks Clef.** `tezgah-setup` refused
33
+ `--jev @cf/cloudflare/clef-flash`, and the same value in
34
+ `TEZGAH_JEV_PROVIDER` chose no carrier. Both now name `clef-flash`, and
35
+ `@cf/cloudflare/clef` names `clef`.
36
+ - **opencode's judge run denies tools at agent level too.** A user's
37
+ `agent.build.permission` beat the inline top-level deny.
38
+ - **The refusal note needs an asked session CLI.** The seam writes it only
39
+ when it asked the session CLI first. A caller that named the CLI gets it.
40
+ - **A chosen carrier reads only its own key.** A `clef` choice no longer opens
41
+ omp's login database on every status-line redraw.
42
+ - **`pnpm test` names a command.** An Acceptance item counted as a command
43
+ only when a word after the first was an option or a path. So `pnpm test`,
44
+ `cargo test` and `make test` read as missing, and the phase move refused the
45
+ plan. A known runner followed by `test` or `run` now counts. Prose such as
46
+ `run the tests` still does not.
47
+ - **Each carrier's price is now pinned by a test.** The one cost check priced a
48
+ TypeSafe answer at Jev's rate. It passed with or without per-carrier pricing.
49
+ New tests in `tezgah-taste`, `tezgah-triage` and the replay labeller price Clef
50
+ and Clef-flash answers at their own rates. A run with an unpriced answer prints
51
+ `cost unknown`.
52
+ - **Claude lists the reviewer once.** The plugin shipped its own
53
+ `agents/tezgah-reviewer.md`, which Claude shows as `tezgah:tezgah-reviewer`
54
+ beside the generated reviewer and never shadows. The plugin now ships no
55
+ agent, and its release-time renderer is gone with it.
56
+
9
57
  ## [1.5.0] - 2026-10-09
10
58
 
11
59
  ### Changed
@@ -933,7 +981,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
933
981
  the copy could not find `hooks/` and died on import. It now links into the
934
982
  installed tree, and also installs the command as `tezgah`.
935
983
 
936
- [Unreleased]: https://github.com/r1z4x/tezgah/compare/v1.5.0...HEAD
984
+ [Unreleased]: https://github.com/r1z4x/tezgah/compare/v1.5.1...HEAD
985
+ [1.5.1]: https://github.com/r1z4x/tezgah/releases/tag/v1.5.1
937
986
  [1.5.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.5.0
938
987
  [1.4.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.4.0
939
988
  [1.3.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.3.0
package/MANIFEST CHANGED
@@ -19,7 +19,6 @@ README.md
19
19
  README.tr.md
20
20
  RELEASING.md
21
21
  SECURITY.md
22
- agents/tezgah-reviewer.md
23
22
  assets/logo/README.md
24
23
  assets/logo/tezgah-logo-16.png
25
24
  assets/logo/tezgah-logo-256.png
package/README.md CHANGED
@@ -109,10 +109,12 @@ it set out to arm is not armed.
109
109
  `~/.codex/AGENTS.md`, `~/.omp/agent/RULES.md`). Claude Code's hooks ship in a
110
110
  plugin registered through `claude plugin`; with no `claude` CLI, Claude stays
111
111
  unarmed and the report says so.
112
- - **Agent files in your repositories.** Inside the configured roots, a session
113
- writes subagent files to `.claude/agents/`, `.codex/agents/` and
114
- `.opencode/agents/`, and lists those directories in the clone's own
115
- `.git/info/exclude` (`TEZGAH_NO_EXCLUDE=1` stops that).
112
+ - **Agent files.** The install writes tezgah's subagent roles to
113
+ `~/.claude/agents/`, `$CODEX_HOME/agents/` (default `~/.codex/agents/`) and
114
+ `~/.omp/agent/agents/`. Inside the configured roots, a session writes them to
115
+ the repository's `.cursor/agents/` and `.opencode/agents/`, and lists those
116
+ directories in the clone's own `.git/info/exclude` (`TEZGAH_NO_EXCLUDE=1`
117
+ stops that).
116
118
  - **A daily update check.** The status line asks for a newer release at most
117
119
  once a day; `~/.config/tezgah/update-check-off` or `TEZGAH_UPDATE_CHECK=0`
118
120
  turns it off.
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:678-946` (`CORE`).
109
+ # cell reads: `hooks/tezgah_policy.py:680-948` (`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
package/bin/tezgah-route CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """tezgah-route - which tier worker a delegated task goes to, and the model table.
2
+ """tezgah-route - which worker a delegated task goes to, and the model table.
3
3
 
4
4
  tezgah-route "<brief>" [--phase P] [--json] the agent to spawn, and why
5
5
  tezgah-route --check the table's age and refresh flags
@@ -9,10 +9,13 @@
9
9
  tezgah-route --report each route's first check, by tier
10
10
 
11
11
  A brief naming stored data, a migration, credentials, the gate or security goes to
12
- tezgah-frontier by rule; otherwise Jev picks the tier (one Choice, ~650 input
13
- tokens, ~0.4 s); with no key or `judge-off`, `--phase`
14
- (explore|code|mechanical|plan|review|research) picks it from the static table, and
15
- with neither the middle tier is used. The brief is sent to the judge seam's
12
+ tezgah-frontier by rule - or to tezgah-security when it only reviews; otherwise
13
+ Jev answers two Choices in one call (~700 input tokens, ~0.4 s): the tier, and
14
+ whether the brief is the whole job of a specialist (tezgah-tester, -docs,
15
+ -security, -ui, -researcher, -reviewer), which is then the agent named. With no
16
+ key or `judge-off`, `--phase review|research` names the reviewer or the
17
+ researcher, another `--phase` (explore|code|mechanical|plan) picks the tier from
18
+ the static table, and with neither the middle tier is used. The brief is sent to the judge seam's
16
19
  provider - the session's own CLI (omp, claude, opencode, cursor, codex) or the Jev
17
20
  model through TypeSafe, OpenRouter System One, Cloudflare Workers AI or a
18
21
  compatible endpoint (TEZGAH_JEV_PROVIDER picks one), else the OpenRouter chat
package/bin/tezgah-setup CHANGED
@@ -13,7 +13,6 @@
13
13
  tezgah-setup --adopt move the predecessor wiring aside
14
14
  tezgah-setup --sync dev: copy this checkout over the Claude plugin copy
15
15
  tezgah-setup --write-manifest release: regenerate the tracked MANIFEST from git
16
- tezgah-setup --write-plugin-agents release: re-render the plugin's agents/ from the role source
17
16
  tezgah-setup --status [PATH] print the armed/used checklist
18
17
  tezgah-setup --agents [PATH] regenerate this repo's subagent set
19
18
  tezgah-setup --deps [--dry-run] install missing optional tools only
@@ -1163,9 +1162,33 @@ def install_claude():
1163
1162
  # (`TEZGAH_CORE_IN_FILE`, hooks/projects-auto-init.py), so a session pays for
1164
1163
  # the contract once.
1165
1164
  print("%s %s (always-on contract)" % (OK, write_rules("claude")))
1165
+ write_user_agents("claude")
1166
1166
  register_claude_plugin()
1167
1167
 
1168
1168
 
1169
+ def write_user_agents(host):
1170
+ """The host's tezgah roles in its user agent dir, where it loads them from
1171
+ the first session (a repo dir written at session start is missed there),
1172
+ then tezgah's MARKER files no longer wanted - `agents-off`, a lost
1173
+ capability, a retired role - are removed. The user's own files stay."""
1174
+ agents_dir = tezgah_agents.user_agent_dir(host)
1175
+ wanted = tezgah_agents.user_agents(host)
1176
+ for name, text in sorted(wanted.items()):
1177
+ path = os.path.join(agents_dir, name)
1178
+ if write_if_changed(path, text):
1179
+ print("%s %s" % (OK, path))
1180
+ for path in tezgah_agents.remove_stale(agents_dir, wanted):
1181
+ print("%s removed %s" % (OK, path))
1182
+
1183
+
1184
+ def user_agent_files(host):
1185
+ """{filename: text} of tezgah's MARKER files in the host's user agent dir."""
1186
+ d = tezgah_agents.user_agent_dir(host)
1187
+ return {n: t for n in (os.listdir(d) if os.path.isdir(d) else [])
1188
+ if n.startswith("tezgah-")
1189
+ and tezgah_agents.MARKER in (t := tezgah_agents._read(os.path.join(d, n)) or "")}
1190
+
1191
+
1169
1192
  # The marketplace a manifest rendered by this installer carries, so Claude
1170
1193
  # installs the plugin as `tezgah@tezgah-local`. A manifest the tree already has
1171
1194
  # under another name (the maintainer's) is kept, name included.
@@ -1358,6 +1381,7 @@ def install_codex():
1358
1381
  # instructions Codex keeps for the whole session. Global, like omp's
1359
1382
  # RULES.md, because a per-project file would be a tracked project file.
1360
1383
  print("%s %s (always-on contract)" % (OK, write_rules("codex")))
1384
+ write_user_agents("codex")
1361
1385
 
1362
1386
 
1363
1387
  def _toml_str(value):
@@ -3293,6 +3317,9 @@ def verify_uninstall(hosts, full):
3293
3317
  # the user's own CLAUDE.md may name tezgah; only the block is ours
3294
3318
  clean("claude global CLAUDE.md block gone", [CLAUDE_RULES],
3295
3319
  needle="<!-- tezgah:start")
3320
+ claim("claude subagents gone",
3321
+ [os.path.join(tezgah_agents.user_agent_dir("claude"), n)
3322
+ for n in user_agent_files("claude")])
3296
3323
  if "codex" in hosts:
3297
3324
  clean("codex hooks.json unwired", [os.path.join(CODEX, "hooks.json")])
3298
3325
  claim("codex skills unlinked",
@@ -3304,6 +3331,9 @@ def verify_uninstall(hosts, full):
3304
3331
  clean("codex global AGENTS.md block gone",
3305
3332
  [os.path.join(CODEX, n) for n in CODEX_RULES],
3306
3333
  needle="<!-- tezgah:start")
3334
+ claim("codex subagents gone",
3335
+ [os.path.join(tezgah_agents.user_agent_dir("codex"), n)
3336
+ for n in user_agent_files("codex")])
3307
3337
  if "opencode" in hosts:
3308
3338
  claim("opencode plugin unlinked",
3309
3339
  [os.path.join(OPENCODE, d, "tezgah.js") for d in ("plugins", "plugin")]
@@ -3454,6 +3484,13 @@ def uninstall_claude():
3454
3484
  unwire_claude_settings()
3455
3485
  drop_claude_mcp_row()
3456
3486
  strip_managed_md(CLAUDE_RULES)
3487
+ remove_user_agents("claude")
3488
+
3489
+
3490
+ def remove_user_agents(host):
3491
+ """Every tezgah MARKER file in the host's user agent dir; the user's stay."""
3492
+ for path in tezgah_agents.remove_stale(tezgah_agents.user_agent_dir(host), ()):
3493
+ print("%s removed %s" % (OK, path))
3457
3494
 
3458
3495
 
3459
3496
  def drop_claude_mcp_row():
@@ -3488,6 +3525,7 @@ def uninstall_codex():
3488
3525
  # both names: the override may have appeared, or gone, since the install
3489
3526
  for name in CODEX_RULES:
3490
3527
  strip_managed_md(os.path.join(CODEX, name))
3528
+ remove_user_agents("codex")
3491
3529
 
3492
3530
 
3493
3531
  def uninstall_opencode():
@@ -4429,6 +4467,8 @@ def host_checks_claude():
4429
4467
  bool(copies) and all(plugin_copy_current(t) for t in copies)),
4430
4468
  ("CLAUDE.md carries the contract (matches a fresh render)",
4431
4469
  rules_current("claude")),
4470
+ ("subagents in %s (match a fresh render)" % tezgah_agents.user_agent_dir("claude"),
4471
+ user_agent_files("claude") == tezgah_agents.user_agents("claude")),
4432
4472
  ] + claude_mcp_rows(copies)
4433
4473
 
4434
4474
 
@@ -4617,6 +4657,8 @@ def host_checks_codex():
4617
4657
  ("MCP in config.toml", "mcp_servers.codegraph" in toml),
4618
4658
  ("%s carries the contract (matches a fresh render)"
4619
4659
  % os.path.basename(codex_rules()), rules_current("codex")),
4660
+ ("subagents in %s (match a fresh render)" % tezgah_agents.user_agent_dir("codex"),
4661
+ user_agent_files("codex") == tezgah_agents.user_agents("codex")),
4620
4662
  ] + mcp_rows(toml_servers(toml_path), "config.toml") + [
4621
4663
  mcp_row(TEZGAH_MCP_NAME, toml_mcp_entry(toml_path, TEZGAH_MCP_NAME),
4622
4664
  "config.toml", tezgah_mcp_command()),
@@ -4929,24 +4971,6 @@ def write_manifest():
4929
4971
  return 0
4930
4972
 
4931
4973
 
4932
- def write_plugin_agents():
4933
- """Re-render the plugin's tracked `agents/*.md` from tezgah_agents' role
4934
- bodies, the source the per-repo agents are generated from. A plain write,
4935
- not write_if_changed: a tracked file needs no `.tezgah-bak` beside it."""
4936
- for name, text in tezgah_agents.plugin_agents().items():
4937
- path = os.path.join(HERE, "agents", name)
4938
- try:
4939
- with open(path, encoding="utf-8") as fh:
4940
- if fh.read() == text:
4941
- continue
4942
- except OSError:
4943
- pass
4944
- with open(path, "w", encoding="utf-8") as fh:
4945
- fh.write(text)
4946
- print("%s %s" % (OK, path))
4947
- return 0
4948
-
4949
-
4950
4974
  def tree_sha(root, files):
4951
4975
  """sha256 over each relpath and its bytes, or None when one is unreadable."""
4952
4976
  h = hashlib.sha256()
@@ -5647,8 +5671,6 @@ def main(argv):
5647
5671
  help="regenerate this repo's subagent set (default: cwd)")
5648
5672
  ap.add_argument("--write-manifest", action="store_true",
5649
5673
  help="release: regenerate the tracked MANIFEST from git")
5650
- ap.add_argument("--write-plugin-agents", action="store_true",
5651
- help="release: re-render the plugin's agents/ from the role source")
5652
5674
  ap.add_argument("--deps", action="store_true")
5653
5675
  ap.add_argument("--mcp-schemas", action="store_true",
5654
5676
  help="measure the MCP tool-schema band by asking each server")
@@ -5695,7 +5717,7 @@ def main(argv):
5695
5717
  help="with --install: the language replies are asked for and "
5696
5718
  "the Stop rule checks (config.json `reply_lang`; `tr` "
5697
5719
  "when unset, `any` checks none)")
5698
- ap.add_argument("--jev", choices=tp.JEV_CHOICES,
5720
+ ap.add_argument("--jev", type=tp.jev_name, choices=tp.JEV_CHOICES,
5699
5721
  help="the carrier the judgement seam reaches the Jev model "
5700
5722
  "through (config.json `jev`; `auto` tries every one "
5701
5723
  "that resolves); TEZGAH_JEV_PROVIDER and JEV_PROVIDER "
@@ -5763,8 +5785,6 @@ def main(argv):
5763
5785
  return 0
5764
5786
  if args.write_manifest:
5765
5787
  return write_manifest()
5766
- if args.write_plugin_agents:
5767
- return write_plugin_agents()
5768
5788
  if args.status:
5769
5789
  print(tezgah_context.health_lines(os.path.realpath(expand(args.status))))
5770
5790
  # The install-health half of this report, from the same function the
package/bin/tezgah-task CHANGED
@@ -329,8 +329,8 @@ def verification_steps(text, path):
329
329
  the budget `review` enforces."""
330
330
  fields = tt.frontmatter(text)
331
331
  ident = fields.get("id") or os.path.basename(path).split("-")[0]
332
- # every item, not only the ones the command reader recognises: `pnpm test`
333
- # carries no option or path and would otherwise vanish from the list
332
+ # every item, not only the ones the command reader recognises: a bare
333
+ # `pytest` is a command it does not read as one, and must not vanish here
334
334
  items = ["%s%s" % (i["text"], "" if i["state"] == "checkable" else
335
335
  " [%s]" % ("unverifiable" if i["state"] == "unverifiable"
336
336
  else "names no command"))
@@ -34,7 +34,7 @@ nothing about a host's event names or output envelope.
34
34
  | `tezgah_paths.py` | where tezgah is armed: [roots](glossary.md#root) and the linked worktrees of a repository under one, kill switches, the config dir, and the writable cache dir - the fallback resolved on use rather than at import, so a gated call does not pay for `tempfile`, `shutil` or `sqlite3` | `hooks/tezgah_paths.py:2-14`, `hooks/tezgah_paths.py::cache_dir`, `hooks/tezgah_paths.py::fallback_cache` |
35
35
  | `tezgah_snapshot.py` | pre-write bytes of every file a write is about to change, and the one explicit restore | `hooks/tezgah_snapshot.py:2-18`, `hooks/tezgah_snapshot.py::capture` |
36
36
  | `tezgah_untrusted.py` | the untrusted-content label on a result from outside the user and workspace, and the taint notice on the next effect | `hooks/tezgah_untrusted.py:2-22`, `hooks/tezgah_untrusted.py::marks` |
37
- | `tezgah_agents.py` | per-repo subagent definitions generated into each host that has an agent surface | `hooks/tezgah_agents.py:2-22`, `hooks/tezgah_agents.py::sync_root` |
37
+ | `tezgah_agents.py` | subagent definitions generated into each host that has an agent surface: user-level for Claude, Codex and omp (written at install), per repo for Cursor and opencode | `hooks/tezgah_agents.py:2-30`, `hooks/tezgah_agents.py::sync_root`, `hooks/tezgah_agents.py::user_agents` |
38
38
  | `tezgah_research.py` | the in-repo research workspace and the check that a protocol predates its results | `hooks/tezgah_research.py:2-85`, `hooks/tezgah_research.py::check` |
39
39
  | `tezgah_index.py` | the detached graph auto-index worker (flock-guarded, bounded retry) | `hooks/tezgah_index.py:2-18`, `hooks/tezgah_index.py::main` |
40
40
 
@@ -72,7 +72,7 @@
72
72
  "semicolons": 6
73
73
  },
74
74
  "docs/models.md": {
75
- "long_sentences": 19,
75
+ "long_sentences": 18,
76
76
  "passive": 8,
77
77
  "replace_words": 0,
78
78
  "semicolons": 9
package/docs/hosts.md CHANGED
@@ -71,6 +71,8 @@ shared config, contract hash and the CLI symlinks every host shell can call
71
71
  `~/.claude/settings.json` - `statusLine` (`bin/tezgah-setup::wire_claude_statusline`) and the attribution
72
72
  off-switch (`bin/tezgah-setup::wire_claude_attribution`) - and the always-on block in
73
73
  `~/.claude/CLAUDE.md` (`CLAUDE_RULES`), Claude's own user-level memory file.
74
+ The subagent roles go to `~/.claude/agents/tezgah-*.md`
75
+ (`bin/tezgah-setup::write_user_agents`).
74
76
  Its hook manifest is the plugin's own
75
77
  `hooks/hooks.json`; no `hooks.json` is written into `~/.claude`. Claude runs a
76
78
  **copy** of the checkout under `~/.claude/plugins/cache`, refreshed by
@@ -84,6 +86,8 @@ shared config, contract hash and the CLI symlinks every host shell can call
84
86
  (one group per event, PreToolUse carrying `CODEX_PRETOOL_MATCHER` at `bin/tezgah-setup::CODEX_PRETOOL_MATCHER`),
85
87
  `skills/*` symlinks, `~/.codex/bin/consult`, and `mcp_servers.*` tables in
86
88
  `config.toml` (`ensure_toml_mcp`, `bin/tezgah-setup::ensure_toml_mcp`).
89
+ The subagent roles go to `$CODEX_HOME/agents/tezgah-*.toml`
90
+ (`bin/tezgah-setup::write_user_agents`).
87
91
  - **cursor** - `install_cursor` (`bin/tezgah-setup::install_cursor`): `~/.cursor/hooks.json`
88
92
  (tezgah entries replaced, a user's Orca entries merged), `skills/*` symlinks,
89
93
  `mcp.json` servers, and `cli-config.json` `statusLine`.
@@ -159,6 +163,11 @@ and drops the later ones.
159
163
  | skills | codex | `.agents/skills` from the cwd up to the repo root, `~/.agents/skills`, `$CODEX_HOME/skills`; one name in two dirs is listed twice, not merged | links in `$CODEX_HOME/skills` |
160
164
  | skills | cursor | `.agents/skills`, `.cursor/skills`, `~/.agents/skills`, `~/.cursor/skills`, then Claude's and Codex's project and user dirs; no documented winner | links in `~/.cursor/skills` |
161
165
  | skills | opencode | its own skill tool is denied; the router lists `~/.config/opencode/skills`, `~/.claude/skills`, `~/.agents/skills`, first wins (`bin/tezgah-setup::skill_groups`) | links and the router |
166
+ | agents | claude | managed, the `claude --agents` flag, `.claude/agents`, `~/.claude/agents`, plugin agents namespaced `plugin:name`; a dir absent at start is not watched | `~/.claude/agents`, written at install |
167
+ | agents | codex | `$CODEX_HOME/agents`, `/etc/codex/agents`, `.codex/agents` (trusted repos only), read at config load | `$CODEX_HOME/agents`, written at install |
168
+ | agents | cursor | `cursor-agent`: `.cursor/agents`, then `.claude/agents`, `.grok/agents`, plugin agents; first name wins; no user dir; read at process start. The editor's docs add `~/.cursor/agents` and `~/.claude/agents` | `.cursor/agents` per repo at session start, in the CLI's line format (`hooks/tezgah_agents.py::render_md_cursor`) |
169
+ | agents | opencode | `~/.config/opencode/agents`, `.opencode/agents`, merged per field | `config` hook injection, `.opencode/agents` as fallback |
170
+ | agents | omp | `.omp/agents`, `~/.omp/agent/agents`, extensions, bundled | `~/.omp/agent/agents`, written at install |
162
171
  | hooks | claude | user `settings.json` rows (Orca's), project rows, the plugin's `hooks/hooks.json` (tezgah's); every matching row runs, none is ordered before another | plugin rows |
163
172
  | hooks | codex, cursor | `hooks.json` array order, then the repo's `.codex/` or `.cursor/hooks.json`; Codex runs a row only once its hash is trusted | own rows after Orca's |
164
173
  | hooks | opencode | every `*.js`/`*.ts` in `plugins/` and `plugin/`; `tezgah.js` is linked into both, and its `globalThis.__tezgahPluginLoaded` guard makes the second load return `{}` | the plugin |
package/docs/judge.md CHANGED
@@ -138,7 +138,7 @@ shape, so a caller that needs a typed answer takes them like Jev.
138
138
  | Carrier | Endpoint | Credential (env, else file, stripped) | Body |
139
139
  |---|---|---|---|
140
140
  | `typesafe` | `https://api.typesafe.ai/v1/systemone` | `TYPESAFE_API_KEY`, else `~/.config/typesafe/key`, else the active omp login (`~/.omp/agent/agent.db`, read-only; `typesafe_key`, `hooks/tezgah_paths.py::typesafe_key`) | `{state, model, questions}` |
141
- | `jev-openrouter` | `https://openrouter.ai/api/v1/systemone` | `OPENROUTER_API_KEY`, else `~/.config/openrouter/key` | the same, a bare `jev-*` id sent as `typesafe/<id>` |
141
+ | `jev-openrouter` | `https://openrouter.ai/api/v1/systemone` | `OPENROUTER_API_KEY`, else `~/.config/openrouter/key` | the same; a bare `jev-*` id is sent as it is, and OpenRouter maps it onto `typesafe/` |
142
142
  | `jev-cloudflare` | `https://api.cloudflare.com/client/v4/accounts/<id>/ai/run` | `JEV_CLOUDFLARE_API_TOKEN`, else `CLOUDFLARE_API_TOKEN`, else `~/.config/cloudflare/token`; the account id from `CLOUDFLARE_ACCOUNT_ID`, else `~/.config/cloudflare/account_id` | `{model: "typesafe/jev", input: {state, questions}}`; the reply is read bare or inside `result` |
143
143
  | `jev-compatible` | `JEV_API_BASE_URL`, used verbatim | `JEV_API_KEY` | TypeSafe's, the model from `TEZGAH_JEV_MODEL` if set |
144
144
  | `clef`, `clef-flash` | `https://api.cloudflare.com/client/v4/accounts/<id>/ai/run/@cf/cloudflare/<name>` | the Cloudflare token and account id above | `{model: "<name>", state, questions}`; the reply read like Cloudflare's above |
@@ -148,6 +148,8 @@ reads, so one export serves both. `TEZGAH_JEV_PROVIDER` chooses the carrier,
148
148
  else `JEV_PROVIDER`, else `jev` in `~/.config/tezgah/config.json`, else `auto`
149
149
  (`jev_choice`, `hooks/tezgah_paths.py::jev_choice`). The values are `auto`,
150
150
  `typesafe`, `openrouter`, `cloudflare`, `compatible`, `clef` and `clef-flash`.
151
+ The Workers AI ids `@cf/cloudflare/clef` and `@cf/cloudflare/clef-flash` name
152
+ the same two (`jev_name`, `hooks/tezgah_paths.py::jev_name`).
151
153
  `tezgah-setup --jev <value>` writes the config key
152
154
  (`set_jev`, `bin/tezgah-setup::set_jev`). `auto`
153
155
  asks every carrier that resolves, in the table's order (`jev_carriers`,
package/docs/layers.md CHANGED
@@ -76,7 +76,7 @@ lessons and research lines.
76
76
  Must not own the loop, the model, the tools or the network. tezgah never runs
77
77
  the work and never rolls back on its own - rollback is the explicit
78
78
  `bin/tezgah-rollback` - and the subagents it defines are rendered into a host's
79
- own agent surface for that host to run (`hooks/tezgah_agents.py:2-22`).
79
+ own agent surface for that host to run (`hooks/tezgah_agents.py:2-30`).
80
80
 
81
81
  ## The boundary, one term at a time
82
82
 
package/docs/models.md CHANGED
@@ -59,21 +59,21 @@ invoices).
59
59
  | Host | Family | Where the model lands |
60
60
  |---|---|---|
61
61
  | Claude Code | anthropic | the agent file's alias and `effort:` (`_model_lines`): an alias follows the provider and keeps the main session's variant, where a full id breaks on Bedrock, Vertex and a gateway (code.claude.com/docs/en/sub-agents) |
62
- | Codex | openai | `model` and `model_reasoning_effort` in `.codex/agents/*.toml` |
62
+ | Codex | openai | `model` and `model_reasoning_effort` in `$CODEX_HOME/agents/*.toml` |
63
63
  | opencode | any | a selector `--refresh` found in `opencode models` for the slot's model under any configured provider (`resolve_opencode`); none found, no model line, so the agent inherits. The slot's effort rides the JSON agent config as `reasoningEffort` (opencode.ai/docs/agents) |
64
64
  | omp | anthropic when the session default is an Anthropic model, any otherwise (`omp_mode`); a non-Anthropic default is `any`, never a silent fall back to the Anthropic column | `task.agentModelOverrides`, which omp resolves before the agent or bundled model (omp's own docs: `omp://task-agent-discovery.md`, *Model and structured-output precedence*); every slot is written, the frontier row included, so no agent in the row runs on the session default; each override is a **fallback chain** (`omp_overrides`, `_chain`): the mode's family first, then what `fallback` in `~/.config/tezgah/config.json` allows (`tp.fallback_policy`) over the families this machine holds a credential for (`funded_families` reads omp's auth store and the provider env keys; a family it cannot see is left out, never guessed in) - `vendor`, the default, keeps only the session vendor's model (`openrouter/anthropic/claude-opus-5.5:<effort>` behind an Anthropic session, `OPENROUTER_ID`), so a 429 never moves an Opus session's subagent onto GPT or GLM; `any` adds every other funded family, rotated per agent; `none` writes the first entry alone - omp splits the chain into per-spawn patterns (omp's own docs: `omp://settings.md (its `retry.fallbackChains` section)`), so one provider's 429 falls through instead of killing the spawn, measured before this: 24 of 26 post-routing 429s hit a subagent's first request, all on one Opus account every slot shared; `apply_omp` also writes `modelRoles.plan` and `.slow` on that frontier row (same record, same ownership) so plan mode designs on it, and their fallbacks go to `retry.fallbackChains.<role>` under the same `fallback` rule, the only chain keys tezgah owns (`default` and provider-keyed chains stay the user's); `apply_omp` writes through the installer's own binary and agent dir, from HOME so a repository's `.omp/config.yml` never leaks machine-wide, and touches only the entries tezgah wrote (recorded in `~/.config/tezgah/models.json` as `omp_written`; a machine written before that record is adopted by its value, `_ours_by_shape`); a changed `fallback` reaches omp on the next `tezgah-route --refresh` or `tezgah-setup --install` |
65
65
  | dsh | - | none: no model setting was found to write |
66
66
 
67
67
  The orchestrator agent keeps `model: inherit`: it is the main thread's own agent.
68
68
 
69
- | Cursor | - | reads `.claude/agents/` today; Cursor wants its own model ids with the effort **inside** the id (`claude-opus-5[effort=high]`, cursor.com/docs/subagents), and no id this table names was verified against Cursor's models page - so a `.cursor/agents/` rendering that pins a model is not generated yet |
69
+ | Cursor | - | `model: inherit` in `.cursor/agents/*.md` (`render_md_cursor`): `cursor-agent` passes the value through as a raw id, an alias such as `opus` is not in its model list, and the effort belongs **inside** the id (`claude-opus-5[effort=high]`, cursor.com/docs/subagents). No id this table names was verified against Cursor's models page, so no tier model is pinned there yet |
70
70
 
71
71
  omp's bundled agents are routed through the same record (omp's own docs: `omp://task-agent-discovery.md`, *Bundled agents*): `scout` on the explore slot, `sonic` on the cheap slot, `task` on the standard slot, and `reviewer`/`security-reviewer` left to inherit, because a review is frontier work. A user's own value for any of them wins (see the ownership rule above). Those two are the gap this leaves: the record carries no entry for them, so a session on a cheap model reviews on that model - routing them is one more `BUNDLED` entry, not decided here.
72
72
 
73
73
  ## What is not routed yet
74
74
 
75
75
  - **Claude Code's built-in subagents** (Explore, general-purpose, Plan) run on the session model. `CLAUDE_CODE_SUBAGENT_MODEL` and a settings `env` block exist but carry no effort, and whether a project agent with a built-in's name replaces it is not stated where it can be cited - so nothing is generated on a guess. What would close it: a documented per-agent effort for built-ins, or a confirmed name-collision rule.
76
- - **Codex's default subagent** (spawned with no agent file): only `.codex/agents/*.toml` carries a model today. What would close it: a documented `[agents]` default settable from a repo-scoped file tezgah already writes.
76
+ - **Codex's default subagent** (spawned with no agent file): only `$CODEX_HOME/agents/*.toml` carries a model today. What would close it: a documented `[agents]` default settable from a file tezgah already writes.
77
77
  - **opencode's built-in agents**: the injected JSON could carry `model`/`reasoningEffort` for them, but the merge semantics for a built-in entry are not documented where they can be cited, and a wrong merge would replace a built-in prompt instead of adding a model.
78
78
  - **dsh**: the model is a plugin config, not an agent field. The composed profile carries `agent-default-model` with `config: {provider: deepseek-official, model: deepseek-flash}` (`dsh --profile web --dump-config`, 2026-09-30) and tezgah's managed patch block already patches plugin config by id (its `llm-pi-ai` row), so a `- id: agent-default-model` row would set the profile's model. Not written, because that knob is one model for the whole session - no `subagent*` entry in the composed tree carries a model - and a single session-wide model is the main thread's setting, which this design deliberately leaves alone ([why the main thread keeps one model](#the-shape)). What would close it: a per-agent model in dsh's config surface.
79
79
 
@@ -91,7 +91,9 @@ fixed:
91
91
  (`OVERRIDE`, `hooks/tezgah_models.py::OVERRIDE`) - the class the judge
92
92
  under-routed in its measurement, and the classes a wrong call cannot be
93
93
  undone on. A 12-brief fixture pins it: 8 high-stakes briefs route to
94
- frontier, 4 near-miss controls do not (`tests/test_models.py`).
94
+ frontier, 4 near-miss controls do not (`tests/test_models.py`). Such a brief
95
+ that only reviews goes to `tezgah-security` instead (`REVIEW_ONLY`,
96
+ `hooks/tezgah_models.py::REVIEW_ONLY`).
95
97
  2. Otherwise the brief - redacted with the ledger's own reader
96
98
  (`redact`, `hooks/tezgah_integrity.py::redact`) - goes to the judge as one Choice over three tiers (`TIER_QUESTION`,
97
99
  `hooks/tezgah_models.py::TIER_QUESTION`). The judge asks the session's own model first ([judge](judge.md)).
@@ -99,9 +101,17 @@ fixed:
99
101
  the same session that wrote the rubric (2026-09-30, twice): under-route 0.025,
100
102
  accuracy 0.925 and 0.900, 392 ms median, about 656 input tokens per call; a
101
103
  keyword rule on the same set under-routed 0.100. The session model was not measured on that set.
102
- 3. With no judgement (`judge-off`, no key, a failed call) `--phase` picks the tier
103
- from the static table (`PHASE_TIER`, `hooks/tezgah_models.py::PHASE_TIER`), and
104
- with no phase the middle tier is used.
104
+ The same call asks a second Choice (`ROLE_QUESTION`,
105
+ `hooks/tezgah_models.py::ROLE_QUESTION`): is the brief the whole job of a
106
+ specialist? A yes names that agent on its own tier (`AGENT_SLOT`,
107
+ `hooks/tezgah_models.py::AGENT_SLOT`). Before this question, sessions
108
+ spawned the five specialists 0 times in 121 delegations (2026-10-07 to 10-09).
109
+ Clef agreed on 9 of 10 briefs this session labelled (2026-10-09).
110
+ 3. With no judgement (`judge-off`, no key, a failed call) `--phase review` and
111
+ `--phase research` name the reviewer and the researcher (`PHASE_AGENT`,
112
+ `hooks/tezgah_models.py::PHASE_AGENT`). Another phase picks the tier from the
113
+ static table (`PHASE_TIER`, `hooks/tezgah_models.py::PHASE_TIER`). With no
114
+ phase the middle tier is used.
105
115
 
106
116
  The brief leaves the machine for the judge, like every judgement ([judge](judge.md)).
107
117
  Every routed call writes one `route` row to the session's ledger. The row names the
@@ -28,7 +28,6 @@ before an install is considered.
28
28
  | `--status [PATH]` | Print the armed/used checklist for PATH (default cwd) and stop — the same line as `bin/tezgah-status` (`bin/tezgah-setup::main`, `hooks/tezgah_context.py::health_lines`). It also prints the report's `config.json hosts (...) match the hosts wired (...)` row when the recorded list and the wiring on disk disagree (`hosts_row`), and nothing when they agree: install health is checked here, not only in a full report. |
29
29
  | `--agents [PATH]` | Regenerate PATH's per-repo subagent set (default cwd); outside a [root](glossary.md#root) it prints `no agents generated` (`bin/tezgah-setup::main`). |
30
30
  | `--write-manifest` | Regenerate the tracked `MANIFEST` from `git ls-files` and stop — the release step, its only writer, and it says so on a tree with no `.git` rather than writing an empty listing (`bin/tezgah-setup::write_manifest`, `bin/tezgah-setup::plugin_files`). |
31
- | `--write-plugin-agents` | Re-render the plugin's tracked `agents/*.md` from the role bodies `hooks/tezgah_agents.py` generates per repo, and stop — the release step that keeps the shipped reviewer from drifting from the generated one; `tests/test_agents.py` fails until it has run (`write_plugin_agents`, `plugin_agents`). |
32
31
  | `--deps` | Install the missing optional tools and stop; with `--install` the install already does it. Like `--install` and `--enable`, it exits 1 when a requested tool is still missing (`bin/tezgah-setup::main`). |
33
32
  | `--host-deps` | With `--install` or `--deps`, also install the missing third-party host CLIs (cursor-agent, pnpm, dsh). They are opt-in because each one is a vendor `curl \| sh` that uninstall does not take back. Every installer runs with a 600 s timeout, each `curl` with `--max-time 300` and each pipeline under `pipefail`, so a stalled or refused download fails instead of hanging or reading as success (audit M-9). |
34
33
  | `--mcp-schemas` | Measure the MCP tool-schema band by asking each registered server (`initialize` + `tools/list`) and stop (`bin/tezgah-setup::main`, `bin/tezgah-setup::mcp_schema_report`). |
package/docs/testing.md CHANGED
@@ -171,7 +171,7 @@ into the test process would answer about the developer's real machine.
171
171
  | `tests/_probe_paths.py` | argv op, no payload (`:11`) | `roots`, `root_for`, `off`, `default_root`, `config`, `off_dirs`, `cache_dir`, `which_user`, `orx_bin`, `consult_options`, `have_typesafe_key`, `codegraph_bin` |
172
172
  | `tests/_probe_context.py` | JSON on stdin, `{"fn": ...}` (`:4-5`) | `context_for`, `health_lines`, `health_segments`, `record` |
173
173
  | `tests/_probe_integrity.py` | JSON on stdin (`:4-6`) | `note`, `note_tool`, `note_turn`, `kinds`, `events`, `prior_calls`, `counters`, `shortcut_command`, `shortcut_edit`, `stop_reason`, `verify_command` |
174
- | `tests/_probe_agents.py` | JSON on stdin (`:3-4`) | `sync_root`, `opencode_json`, `cleanup`, `detect` |
174
+ | `tests/_probe_agents.py` | JSON on stdin (`:3-4`) | `sync_root`, `opencode_json`, `cleanup`, `detect`, `omp`, `user`, `steering` |
175
175
  | `tests/_probe_gate.py` | JSON on stdin (`:4-8`) | the shared gate `decision(tool, input, cwd, session_id)`; `capture_log` plants a stub `tezgah_snapshot` in `sys.modules`, so the snapshot call site is pinned whether or not that module has landed |
176
176
 
177
177
  A probe is invoked through `support.run`/`run_json` with its path constant
@@ -1,16 +1,24 @@
1
1
  #!/usr/bin/env python3
2
- """Per-repo subagent definitions, generated from the detected infrastructure.
3
-
4
- When a session starts inside a tezgah root, the enclosing repo gets a small set
5
- of specialized agents, rendered into every host surface that actually exists:
6
-
7
- Claude Code / Cursor `.claude/agents/*.md` (Cursor reads this dir natively)
8
- opencode `.opencode/agents/*.md` (fallback) + config injection
9
- Codex `.codex/agents/*.toml`
10
-
11
- A role is only emitted when the capability it needs is present (the reviewer
12
- needs the code graph); the three tier workers are emitted in every root,
13
- because `tezgah-route` names one of them for every delegated task. The whole set is regenerated when either this manifest
2
+ """Subagent definitions, generated from the detected infrastructure.
3
+
4
+ One role set, rendered into every host surface that actually exists, at the
5
+ place each host loads it from:
6
+
7
+ Claude Code `~/.claude/agents/*.md` user-level, written at install
8
+ Codex `$CODEX_HOME/agents/*.toml` user-level, written at install
9
+ omp `~/.omp/agent/agents/*.md` user-level, written at install
10
+ Cursor `.cursor/agents/*.md` per repo, at session start
11
+ opencode `.opencode/agents/*.md` (fallback) + config injection, per repo
12
+
13
+ Claude and Codex miss a repo agents dir written during their first session
14
+ (Claude does not watch a dir that was absent at start; Codex reads roles at
15
+ config load and ignores `.codex/agents` in an untrusted repo), so their roles
16
+ live user-level; a repo's `.claude/agents` and `.codex/agents` files an older
17
+ tezgah wrote are swept. Cursor's CLI reads no user dir, so it keeps a per-repo
18
+ dir. A role is only emitted when the capability it needs is present (the
19
+ reviewer needs the code graph); the three tier workers are emitted in every root,
20
+ because `tezgah-route` names one of them for every delegated task. The per-repo
21
+ set is regenerated when either this manifest
14
22
  or the repo's infrastructure changes, so the definitions stay updatable instead
15
23
  of drifting. Hosts with no such surface (dsh) get nothing here; the orchestrator
16
24
  directive in the injected contract already covers them. The generated dirs are
@@ -30,16 +38,20 @@ import subprocess
30
38
  import tezgah_models as tm
31
39
  from tezgah_paths import (CONFIG_DIR, HOME, codegraph_bin, host_installed, off,
32
40
  root_for)
41
+ from tezgah_paths import HOST_DIRS as HOST_HOMES
33
42
 
34
43
  MARKER = "# tezgah: managed by tezgah-agents; do not edit"
35
44
  STATE = os.path.join(CONFIG_DIR, "agents.state.json")
36
- # hosts with a file-based custom-agent surface, and where it lives, relative to
37
- # the repo. Cursor reads .claude/agents/ natively, so one markdown dir serves
38
- # both when either is installed; the *detection* of whether a host is installed
39
- # lives in tezgah_paths.host_installed, not here.
45
+ # hosts with a file-based custom-agent surface; the *detection* of whether a
46
+ # host is installed lives in tezgah_paths.host_installed, not here.
40
47
  FILE_HOSTS = ("claude", "opencode", "codex", "cursor")
48
+ # the file hosts whose roles live in their user dir (user_agents), written by
49
+ # the installer; the others get a per-repo dir from sync_root
50
+ USER_HOSTS = ("claude", "codex")
51
+ # the repo-relative agent dirs. Claude's and Codex's are no longer written, only
52
+ # swept: a copy an older tezgah left there would sit beside the user-level one.
41
53
  HOST_DIRS = {"claude": os.path.join(".claude", "agents"),
42
- "cursor": os.path.join(".claude", "agents"),
54
+ "cursor": os.path.join(".cursor", "agents"),
43
55
  "opencode": os.path.join(".opencode", "agents"),
44
56
  "codex": os.path.join(".codex", "agents")}
45
57
  # Stack markers -> a short hint. Exact commands are left to the repo's own docs
@@ -61,10 +73,11 @@ MCP_PREFIXES = ("mcp__codegraph__", "mcp__plugin_tezgah_codegraph__")
61
73
  GRAPH_SELECT = ",".join(p + t for t in GRAPH_MCP for p in MCP_PREFIXES)
62
74
  GRAPH_CLI = ("callers", "callees", "impact", "node", "files")
63
75
  GRAPH_TOOLS = ", ".join("`codegraph %s`" % verb for verb in GRAPH_CLI)
64
- # A role with no shell (Claude `disallowedTools` Bash, opencode `bash: deny`)
76
+ # A role with no shell (Claude `disallowedTools` Bash, opencode `bash: deny`;
77
+ # Cursor's `readonly` documents no shell scope, so it is held to the same recipe)
65
78
  # cannot run `git diff` or the CLI, so its blast radius comes from the caller's
66
79
  # brief and the MCP impact tool; a role with a shell runs the diff itself.
67
- NO_SHELL = ("claude", "opencode")
80
+ NO_SHELL = ("claude", "opencode", "cursor")
68
81
  # The floor every graph role carries (gortex edge provenance): the index holds
69
82
  # only the edges its parser saw, so an absence of callers is a coverage gap
70
83
  # until shown otherwise, never a safety verdict.
@@ -114,7 +127,7 @@ def _graph_howto(host):
114
127
  "`codegraph status`). Do not call the `xd://mcp__codegraph_*`\n"
115
128
  "devices: omp refuses them with \"Concurrent write refused\"\n"
116
129
  "while the parent session holds the index." % GRAPH_TOOLS)
117
- if host == "opencode":
130
+ if host in ("opencode", "cursor"):
118
131
  return ("codegraph is registered as an MCP server (%s). You have no\n"
119
132
  "shell, so the codegraph CLI is out of reach: use those MCP tools."
120
133
  % ", ".join("`%s`" % t for t in GRAPH_MCP))
@@ -440,10 +453,8 @@ def _claude_model(model):
440
453
 
441
454
  def _model_lines(name, family, model_fmt, effort_fmt):
442
455
  """The model (and effort) lines the table gives this agent on a family;
443
- `model: inherit` on Claude/Cursor for an agent with no slot. Cursor reads
444
- this same file but wants its own ids and the effort inside the id
445
- (`claude-opus-5[effort=high]`, cursor.com/docs/subagents) - unverified for
446
- the ids this table names, so Cursor is documented, not guessed at."""
456
+ `model: inherit` on Claude for an agent with no slot. Cursor gets
457
+ `model: inherit` always (render_md_cursor)."""
447
458
  picked = tm.pick(name, family)
448
459
  if not picked:
449
460
  return ["model: inherit"] if family == "anthropic" else []
@@ -462,35 +473,34 @@ def _fold(desc, width=74):
462
473
 
463
474
 
464
475
  def render_md(name, description, readonly, body):
465
- """Claude Code / Cursor markdown. Cursor ignores unknown keys and honours
466
- `readonly`; Claude denies the write/shell tools explicitly."""
476
+ """Claude Code markdown (`~/.claude/agents`), which the Cursor editor also
477
+ reads (cursor.com/docs/subagents). The description is one line: Cursor's
478
+ line parser reads a folded `>` block as ">", and Claude's parser re-quotes
479
+ a plain value YAML rejects (a `: ` inside it). Claude denies the write/shell
480
+ tools explicitly; `readonly` is Cursor's key, which Claude ignores."""
467
481
  lines = ["---", MARKER + " (manifest %s)" % manifest_sha(),
468
- "name: %s" % name, "description: >"]
469
- lines += _fold(description)
482
+ "name: %s" % name, "description: %s" % " ".join(description.split())]
470
483
  lines += _model_lines(name, "anthropic", "model: %s", "effort: %s")
471
484
  if readonly:
472
- # `disallowedTools` is Claude's; `readonly` is Cursor's. Both parsers
473
- # ignore the other's field.
474
485
  lines.append("readonly: true")
475
486
  lines.append("disallowedTools: Write, Edit, NotebookEdit, Bash, Agent")
476
487
  lines.append("---")
477
488
  return "\n".join(lines) + "\n\n" + body.strip() + "\n"
478
489
 
479
490
 
480
- def plugin_agents():
481
- """{filename: text} for the plugin's own tracked `agents/` dir.
482
-
483
- The graph roles' Claude render, from the same body the per-repo files get,
484
- so the shipped copy cannot drift from the generated one. The MARKER line is
485
- dropped: the copy is tracked, never swept by `_remove_stale`, and its
486
- manifest sha would turn every edit of this module into a stale file.
487
- `tezgah-setup --write-plugin-agents` writes it; a test pins it."""
488
- out = {}
489
- for name, desc, _cap, body, readonly in ROLES:
490
- if name in GRAPH_ROLES:
491
- lines = render_md(name, desc, readonly, body("claude")).split("\n")
492
- out[name + ".md"] = "\n".join(lines[:1] + lines[2:])
493
- return out
491
+ def render_md_cursor(name, description, readonly, body):
492
+ """Cursor `.cursor/agents/*.md`. cursor-agent splits each frontmatter line
493
+ at its first ':' (no YAML), passes `model` through as a raw id and has no
494
+ `effort` key, so: a one-line description, `model: inherit` (the only id
495
+ portable across Cursor accounts), `readonly`, and no `tools` line - its
496
+ comma split would cut a Claude `Agent(a, b)` list into broken names."""
497
+ lines = ["---", MARKER + " (manifest %s)" % manifest_sha(),
498
+ "name: %s" % name, "description: %s" % " ".join(description.split()),
499
+ "model: inherit"]
500
+ if readonly:
501
+ lines.append("readonly: true")
502
+ lines.append("---")
503
+ return "\n".join(lines) + "\n\n" + body.strip() + "\n"
494
504
 
495
505
 
496
506
  def render_md_opencode(name, description, readonly, body):
@@ -529,13 +539,19 @@ def render_md_omp(name, description, body, tools=("read", "grep", "glob", "bash"
529
539
  return "\n".join(lines) + "\n\n" + body.strip() + "\n"
530
540
 
531
541
 
542
+ def _active_roles(root):
543
+ """The roles a user-level dir holds: none under `agents-off`, else those
544
+ whose capability the machine has (`~/.no-graph` counts as the repo's)."""
545
+ if off("agents-off"):
546
+ return []
547
+ infra = detect_infra(os.path.expanduser(root))
548
+ return [r for r in ROLES if r[2](infra)]
549
+
550
+
532
551
  def omp_user_agents(root="~"):
533
552
  """{filename: text} for omp's user agent dir (~/.omp/agent/agents); empty
534
553
  under `agents-off`, so the installer's sweep removes the files."""
535
- if off("agents-off"):
536
- return {}
537
- infra = detect_infra(os.path.expanduser(root))
538
- active = [r for r in ROLES if r[2](infra)]
554
+ active = _active_roles(root)
539
555
  names = [r[0] for r in active]
540
556
  if not names:
541
557
  return {}
@@ -550,20 +566,50 @@ def omp_user_agents(root="~"):
550
566
  return out
551
567
 
552
568
 
569
+ def user_agent_dir(host):
570
+ """Where a USER_HOSTS host loads agents from in every session: Claude's
571
+ `~/.claude/agents`, Codex's `$CODEX_HOME/agents` (default ~/.codex)."""
572
+ return os.path.join(HOST_HOMES[host], "agents")
573
+
574
+
575
+ def user_agents(host, root="~"):
576
+ """{filename: text} for a USER_HOSTS host's user agent dir, each file
577
+ carrying MARKER so the installer sweeps only tezgah's; empty under
578
+ `agents-off`, so that sweep removes them. Codex gets no orchestrator: the
579
+ main thread orchestrates there."""
580
+ active = _active_roles(root)
581
+ names = [r[0] for r in active]
582
+ if not names:
583
+ return {}
584
+ if host == "codex":
585
+ return {name + ".toml": render_toml(name, desc, readonly, body("codex"))
586
+ for name, desc, _cap, body, readonly in active}
587
+ out = {name + ".md": render_md(name, desc, readonly, body("claude"))
588
+ for name, desc, _cap, body, readonly in active}
589
+ out["tezgah-orchestrator.md"] = render_orch_md(names)
590
+ return out
591
+
592
+
553
593
  def steering(root, host=None):
554
594
  """One line naming the generated specialists this host can spawn, or None.
555
595
 
556
596
  Nothing else in the contract points the main agent at them, so a model
557
597
  reached for the host's generic explorer instead. Existence is read from
558
598
  disk, not from the capabilities: a specialist is named only when its file
559
- is there for the host to load - omp's user agent dir on omp, the repo's
560
- generated dirs everywhere else. A repo's `.no-graph` turns the graph off
561
- there, so a graph role is not named in it even when omp's user-level file
599
+ is there for the host to load - the user agent dir on omp, Claude and
600
+ Codex, the repo's own dir on Cursor and opencode, and all of them for a
601
+ host that does not say which it is. A repo's `.no-graph` turns the graph
602
+ off there, so a graph role is not named in it even when a user-level file
562
603
  exists."""
563
604
  if host == "omp":
564
605
  dirs = [os.path.join(HOME, ".omp", "agent", "agents")]
606
+ elif host in USER_HOSTS:
607
+ dirs = [user_agent_dir(host)]
608
+ elif host in HOST_DIRS:
609
+ dirs = [os.path.join(root, HOST_DIRS[host])]
565
610
  else:
566
- dirs = [os.path.join(root, d) for d in set(HOST_DIRS.values())]
611
+ dirs = ([user_agent_dir(h) for h in USER_HOSTS]
612
+ + [os.path.join(root, HOST_DIRS[h]) for h in ("cursor", "opencode")])
567
613
  have = {os.path.splitext(os.path.basename(p))[0]
568
614
  for d in dirs for p in glob.glob(os.path.join(d, "tezgah-*.*"))}
569
615
  if os.path.exists(os.path.join(root, ".no-graph")):
@@ -656,14 +702,18 @@ def _write_if_changed(path, text):
656
702
  return True
657
703
 
658
704
 
659
- def _remove_stale(directory, wanted):
660
- """Delete only tezgah-managed files no longer in the wanted set."""
705
+ def remove_stale(directory, wanted):
706
+ """Delete only tezgah-managed files no longer in the wanted set; returns
707
+ the paths removed."""
708
+ removed = []
661
709
  for path in glob.glob(os.path.join(directory, "tezgah-*.*")):
662
710
  if os.path.basename(path) not in wanted and _is_managed(_read(path)):
663
711
  try:
664
712
  os.remove(path)
713
+ removed.append(path)
665
714
  except OSError:
666
715
  pass
716
+ return removed
667
717
 
668
718
 
669
719
  BLOCK_BEGIN = ("# tezgah: generated agents (managed; removed by "
@@ -796,66 +846,49 @@ def sync_root(root, report_steady=False):
796
846
  return None
797
847
  disabled = off("agents-off")
798
848
  infra = detect_infra(root)
799
- hosts = set() if disabled else set(infra["hosts"])
849
+ # Claude's and Codex's roles are user-level (user_agents): only Cursor and
850
+ # opencode get a dir here
851
+ hosts = set() if disabled else set(infra["hosts"]) - set(USER_HOSTS)
800
852
  active = [] if disabled else [r for r in ROLES if r[2](infra)]
801
853
  names = [r[0] for r in active]
802
854
 
803
- # markdown: one dir serves Claude and Cursor (Cursor reads .claude/agents/)
804
- md_dir = os.path.join(root, HOST_DIRS["claude"]) if hosts & {"claude", "cursor"} else None
855
+ cursor_dir = os.path.join(root, HOST_DIRS["cursor"]) if "cursor" in hosts else None
805
856
  oc_dir = os.path.join(root, HOST_DIRS["opencode"]) if "opencode" in hosts else None
806
- codex_dir = os.path.join(root, HOST_DIRS["codex"]) if "codex" in hosts else None
807
857
 
808
858
  written, removed = 0, 0
809
859
  paths = []
860
+ wanted = {h: set() for h in HOST_DIRS}
810
861
 
811
- def emit(directory, wanted, name, text):
862
+ def emit(host, name, text):
812
863
  nonlocal written
813
- wanted.add(name)
814
- path = os.path.join(directory, name)
864
+ wanted[host].add(name)
865
+ path = os.path.join(root, HOST_DIRS[host], name)
815
866
  if _write_if_changed(path, text):
816
867
  written += 1
817
868
  paths.append(path)
818
869
 
819
- wanted_md, wanted_oc, wanted_codex = set(), set(), set()
820
-
821
- def sweep(directory, wanted):
822
- nonlocal removed
823
- before = set(os.path.basename(p) for p in glob.glob(os.path.join(directory, "tezgah-*.*")))
824
- _remove_stale(directory, wanted)
825
- after = set(os.path.basename(p) for p in glob.glob(os.path.join(directory, "tezgah-*.*")))
826
- removed += len(before - after)
827
-
828
- if md_dir and names: # Claude Code + Cursor
870
+ # Cursor gets no orchestrator: its files are subagents the main thread
871
+ # delegates to, and the orchestrator is a primary agent
872
+ if cursor_dir and names:
829
873
  for name, desc, _cap, body, readonly in active:
830
- emit(md_dir, wanted_md, name + ".md", render_md(name, desc, readonly, body("claude")))
831
- emit(md_dir, wanted_md, "tezgah-orchestrator.md", render_orch_md(names))
874
+ emit("cursor", name + ".md",
875
+ render_md_cursor(name, desc, readonly, body("cursor")))
832
876
 
833
877
  if oc_dir and names: # opencode
834
878
  for name, desc, _cap, body, readonly in active:
835
- emit(oc_dir, wanted_oc, name + ".md",
879
+ emit("opencode", name + ".md",
836
880
  render_md_opencode(name, desc, readonly, body("opencode")))
837
- emit(oc_dir, wanted_oc, "tezgah-orchestrator.md", render_orch_md_opencode(names))
838
-
839
- if codex_dir and names: # Codex (no orchestrator agent; the main thread orchestrates)
840
- for name, desc, _cap, body, readonly in active:
841
- emit(codex_dir, wanted_codex, name + ".toml",
842
- render_toml(name, desc, readonly, body("codex")))
881
+ emit("opencode", "tezgah-orchestrator.md", render_orch_md_opencode(names))
843
882
 
844
- # The sweep runs over every dir this module can write, not only the selected
845
- # ones: a host dropped from the config, or a capability that disappeared,
846
- # must not leave tezgah agents behind for the host to keep loading.
847
- sweep(os.path.join(root, HOST_DIRS["claude"]), wanted_md)
848
- sweep(os.path.join(root, HOST_DIRS["opencode"]), wanted_oc)
849
- sweep(os.path.join(root, HOST_DIRS["codex"]), wanted_codex)
883
+ # The sweep runs over every dir this module ever wrote, not only the
884
+ # selected ones: a host dropped from the config, a capability that
885
+ # disappeared, or a `.claude`/`.codex` copy from before the roles moved
886
+ # user-level must not stay behind for a host to keep loading.
887
+ for host, directory in HOST_DIRS.items():
888
+ removed += len(remove_stale(os.path.join(root, directory), wanted[host]))
850
889
 
851
890
  if paths:
852
- dirs = set()
853
- if md_dir:
854
- dirs.add(HOST_DIRS["claude"])
855
- if oc_dir:
856
- dirs.add(HOST_DIRS["opencode"])
857
- if codex_dir:
858
- dirs.add(HOST_DIRS["codex"])
891
+ dirs = {HOST_DIRS[h] for h in ("cursor", "opencode") if wanted[h]}
859
892
  ex = ensure_exclude(root, dirs)
860
893
  if ex:
861
894
  paths.append(ex)
@@ -868,7 +901,7 @@ def sync_root(root, report_steady=False):
868
901
  % (len(names), ", %d removed" % removed if removed else ""))
869
902
  if removed:
870
903
  return "%d stale agent file(s) removed" % removed
871
- if report_steady and names and (md_dir or oc_dir or codex_dir):
904
+ if report_steady and names and (cursor_dir or oc_dir):
872
905
  return "%d agent(s) current" % len(names)
873
906
  return None
874
907
 
@@ -690,7 +690,8 @@ def autoindex(root):
690
690
 
691
691
 
692
692
  def sync_agents(root):
693
- """Generate/refresh this repo's per-host subagent definitions (best effort)."""
693
+ """Generate/refresh this repo's Cursor and opencode subagent files (best
694
+ effort); Claude's and Codex's roles are user-level, written at install."""
694
695
  try:
695
696
  from tezgah_agents import sync_root
696
697
  return sync_root(root)
@@ -65,7 +65,8 @@ import tezgah_store
65
65
  URL = "https://api.typesafe.ai/v1/systemone"
66
66
  MODEL = "jev-latest"
67
67
  # The other Jev carriers (`JEV_CARRIERS`): OpenRouter's System One endpoint,
68
- # which takes TypeSafe's body and namespaces the model `typesafe/<id>`; and
68
+ # which takes TypeSafe's body and maps a bare `jev-*` id onto `typesafe/` itself
69
+ # (`typesafe/jev-latest` is no model there: a live 400 on 2026-10-09); and
69
70
  # Cloudflare Workers AI, whose run route wraps the state and questions in
70
71
  # `input` and serves the one alias `typesafe/jev`. A System One-compatible URL
71
72
  # is the user's own (`JEV_API_BASE_URL`), so it has no constant here.
@@ -157,7 +158,7 @@ def _jev_call(provider, model, state, questions):
157
158
  return CLOUDFLARE_MODEL, url, {"model": CLOUDFLARE_MODEL, "input": {
158
159
  "state": state, "questions": questions}}
159
160
  if provider == "jev-openrouter":
160
- used = model if "/" in model else "typesafe/" + model
161
+ used = model
161
162
  url = override("TEZGAH_JEV_OPENROUTER_URL", JEV_OPENROUTER_URL)
162
163
  elif provider == "jev-compatible":
163
164
  used = os.environ.get("TEZGAH_JEV_MODEL", "").strip() or model
@@ -215,12 +216,17 @@ SESSION_ARGV = {
215
216
  # prompt asked for; the judge's state can be a screen's untrusted text, so the
216
217
  # run gets an inline config (`OPENCODE_CONFIG_CONTENT`) that denies every tool -
217
218
  # the same probe with it wrote nothing. cursor-agent's `--mode ask` refused the
218
- # same shell call, and codex's `-s read-only` sandbox has no network.
219
+ # same shell call, and codex's `-s read-only` sandbox has no network. Inline
220
+ # config overrides the user's files only key by key, and an agent's own
221
+ # `permission` wins over the top-level one, so the deny is repeated for the two
222
+ # built-in agents `run` uses.
223
+ _DENY_ALL = {"*": "deny", "bash": "deny", "edit": "deny", "webfetch": "deny",
224
+ "external_directory": "deny"}
219
225
  SESSION_ENV = {
220
226
  "opencode": {"OPENCODE_CONFIG_CONTENT": json.dumps({
221
- "permission": {"*": "deny", "bash": "deny", "edit": "deny", "webfetch": "deny",
222
- "external_directory": "deny"},
223
- "tools": {"*": False}})},
227
+ "permission": _DENY_ALL, "tools": {"*": False},
228
+ "agent": {name: {"permission": _DENY_ALL, "tools": {"*": False}}
229
+ for name in ("build", "plan")}})},
224
230
  }
225
231
  NO_SESSION = ("no session CLI to ask (no omp, Claude Code, opencode, Cursor or Codex "
226
232
  "session marker or TEZGAH_JUDGE_CLI/judge_cli pick, or that CLI missing)")
@@ -386,8 +392,9 @@ def ask(state, questions, *, model=MODEL, timeout=30, attempts=2, deadline=None,
386
392
  return result
387
393
  refused = None
388
394
  session = tp.session_cli()
389
- if only is None and session and (openrouter_key()
390
- or any(s for _, s in tp.jev_carriers())):
395
+ # only a session CLI that was asked can have failed before a refusal
396
+ if session in (p for p, _ in tried) and (openrouter_key()
397
+ or any(s for _, s in tp.jev_carriers())):
391
398
  refused = ("fallback=%s refuses a third-party judge after %s failed"
392
399
  % (tp.fallback_policy(), session))
393
400
  _record(None, None, failed="; ".join(failed), refused=refused)
@@ -709,6 +709,39 @@ TIER_QUESTION = {
709
709
  "a hard-to-reverse decision",
710
710
  },
711
711
  }
712
+ # The second question in the same batched call: whether the brief is one of the
713
+ # specialists' jobs. Before it existed route() could only name a tier, and from
714
+ # 2026-10-07 to 10-09 the five specialists were spawned 0 times in 121
715
+ # delegations while the generic `task` agent took 86. Every specialist but the
716
+ # docs writer is read-only or check-only, so a brief that changes code is `none`.
717
+ ROLE_QUESTION = {
718
+ "type": "choice",
719
+ "instructions": (
720
+ "Is `task` the whole job of one of these specialist agents? Pick `none` "
721
+ "when the task writes or changes source code, or mixes a specialist job "
722
+ "with code changes."),
723
+ "criteria": {
724
+ "none": "writing or changing code, or anything not listed below",
725
+ "tester": "only running the checks or tests a change reaches and reporting "
726
+ "their output; no edits",
727
+ "docs": "writing or repairing documentation pages or their path:line "
728
+ "citations",
729
+ "security": "a read-only security review of a change or surface",
730
+ "ui": "inspecting a running web or mobile app's screens against a design "
731
+ "floor or accessibility",
732
+ "researcher": "a research line: hypotheses, literature, experiments, a report",
733
+ "reviewer": "an adversarial read-only review of a diff, branch or pull request",
734
+ },
735
+ }
736
+ ROLE_AGENT = {role: "tezgah-" + role for role in ROLE_QUESTION["criteria"] if role != "none"}
737
+ # Without a judgement, the phase names the specialist where it is one.
738
+ PHASE_AGENT = {"review": "tezgah-reviewer", "research": "tezgah-researcher"}
739
+ # An override brief never leaves the machine, so the rule decides the one
740
+ # specialist it can name: a security review that writes nothing.
741
+ REVIEW_ONLY = re.compile(r"\b(?:review|audit|assess)\w*\b", re.I)
742
+ # `change` and `update` are left out: in "review the auth change" they are nouns.
743
+ WRITES = re.compile(r"\b(?:fix|implement|edit|write|add|refactor|patch|migrate|"
744
+ r"rename|delete|remove|rotate)\w*\b", re.I)
712
745
 
713
746
 
714
747
  def route(brief, phase=None, ask=None):
@@ -724,23 +757,37 @@ def route(brief, phase=None, ask=None):
724
757
  reply is the same as no reply and this never raises."""
725
758
  hit = OVERRIDE.search(brief or "")
726
759
  if hit:
760
+ if REVIEW_ONLY.search(brief) and not WRITES.search(brief):
761
+ return _agent("tezgah-security", "override %r, review only" % hit.group(0),
762
+ "rule")
727
763
  return _pick("frontier", "override: %r" % hit.group(0), "rule")
728
- result = ask({"task": ti.redact(brief or "")}, {"tier": TIER_QUESTION}) if ask else None
764
+ result = ask({"task": ti.redact(brief or "")},
765
+ {"tier": TIER_QUESTION, "role": ROLE_QUESTION}) if ask else None
729
766
  choice = tj.choice(result, "tier")
730
767
  if choice in JEV_TIER:
731
768
  chance = tj.noul(result, "tier", choice)
732
- out = _pick(JEV_TIER[choice], "jev %s%s" % (
733
- choice, "" if chance is None else " %.2f" % chance), "jev")
769
+ why = "jev %s%s" % (choice, "" if chance is None else " %.2f" % chance)
770
+ role = tj.choice(result, "role")
771
+ out = (_agent(ROLE_AGENT[role], "%s, role %s" % (why, role), "jev")
772
+ if role in ROLE_AGENT else _pick(JEV_TIER[choice], why, "jev"))
734
773
  out["judge"] = "%s/%s" % (result.get("provider") or "-",
735
774
  result.get("model") or "-")
736
775
  out["judged"] = result
737
776
  return out
777
+ if phase in PHASE_AGENT:
778
+ return _agent(PHASE_AGENT[phase], "no judgement; phase %s names it" % phase,
779
+ "phase")
738
780
  if phase in PHASE_TIER:
739
781
  return _pick(PHASE_TIER[phase], "no judgement; static table for phase %s" % phase,
740
782
  "phase")
741
783
  return _pick("standard", "no judgement and no phase; the middle tier", "default")
742
784
 
743
785
 
786
+ def _agent(agent, why, via):
787
+ """A specialist, on the tier its model slot names (`AGENT_SLOT`)."""
788
+ return dict(_pick(AGENT_SLOT[agent], why, via), agent=agent)
789
+
790
+
744
791
  def _pick(tier, why, via):
745
792
  return {"agent": "tezgah-" + tier, "tier": tier, "why": why, "via": via,
746
793
  "judge": None, "judged": None}
@@ -592,17 +592,25 @@ JEV_CHOICES = ("auto", "typesafe", "openrouter", "cloudflare", "compatible",
592
592
  "clef", "clef-flash")
593
593
 
594
594
 
595
+ def jev_name(value):
596
+ """A carrier choice as written by a user, lowercased and with the Workers AI
597
+ catalog prefix dropped: `@cf/cloudflare/clef-flash` is what Cloudflare's
598
+ dashboard and docs show, and it names the `clef-flash` carrier."""
599
+ value = value.strip().lower()
600
+ return value[len("@cf/cloudflare/"):] if value.startswith("@cf/cloudflare/") else value
601
+
602
+
595
603
  def jev_choice():
596
604
  """TEZGAH_JEV_PROVIDER, else JEV_PROVIDER (jev-mcp's name), else config.json
597
- `jev`, else `auto` - lowercased. An unknown value is kept, so it resolves to
598
- no carrier instead of silently to another one."""
605
+ `jev`, else `auto` - through `jev_name`. An unknown value is kept, so it
606
+ resolves to no carrier instead of silently to another one."""
599
607
  for env in ("TEZGAH_JEV_PROVIDER", "JEV_PROVIDER"):
600
- value = os.environ.get(env, "").strip().lower()
608
+ value = jev_name(os.environ.get(env, ""))
601
609
  if value:
602
610
  return value
603
611
  cfg = config()
604
612
  value = cfg.get("jev") if isinstance(cfg, dict) else None
605
- value = value.strip().lower() if isinstance(value, str) else ""
613
+ value = jev_name(value) if isinstance(value, str) else ""
606
614
  return value or "auto"
607
615
 
608
616
 
@@ -610,18 +618,25 @@ def jev_carriers():
610
618
  """[(carrier, secret), ...] the chosen Jev carriers in the order the seam
611
619
  asks them. `auto`: every carrier whose credential resolves. A named one: that
612
620
  carrier alone, its secret None when it does not resolve - the seam records
613
- why rather than substituting another carrier."""
614
- compatible = (os.environ.get("JEV_API_KEY", "").strip() or None
615
- if os.environ.get("JEV_API_BASE_URL", "").strip() else None)
616
- workers_ai = cloudflare_token() if cloudflare_account() else None
617
- found = [("typesafe", typesafe_key()), ("jev-openrouter", openrouter_key()),
621
+ why rather than substituting another carrier. Only the chosen carrier's
622
+ credential is read: the status line asks this on every redraw, and the
623
+ TypeSafe channel ends in omp's login database."""
624
+ def compatible():
625
+ return (os.environ.get("JEV_API_KEY", "").strip() or None
626
+ if os.environ.get("JEV_API_BASE_URL", "").strip() else None)
627
+
628
+ def workers_ai():
629
+ return cloudflare_token() if cloudflare_account() else None
630
+ found = [("typesafe", typesafe_key), ("jev-openrouter", openrouter_key),
618
631
  ("jev-cloudflare", workers_ai), ("jev-compatible", compatible),
619
632
  ("clef", workers_ai), ("clef-flash", workers_ai)]
620
633
  choice = jev_choice()
621
634
  if choice == "auto":
622
- return [(name, secret) for name, secret in found if secret]
635
+ return [(name, secret) for name, secret in ((n, read()) for n, read in found)
636
+ if secret]
623
637
  name = choice if choice in ("typesafe", "clef", "clef-flash") else "jev-" + choice
624
- return [(name, dict(found).get(name))]
638
+ read = dict(found).get(name)
639
+ return [(name, read() if read else None)]
625
640
 
626
641
 
627
642
  # How far a model call may leave the session's own vendor (config.json
@@ -303,8 +303,10 @@ active --json` and track it with `orca worktree ps --json` or `tezgah-status
303
303
  status in Orca; outside Orca, `git worktree add`. Either way the brief names
304
304
  absolute paths under that checkout.
305
305
  Routing: `{ROUTE_BIN} "<brief>"` names the worker - tezgah-cheap, -standard or
306
- -frontier (overrides for stored data, credentials, security first; then a Jev
307
- judgement; the static phase table with no key). A worker that answers
306
+ -frontier, or the specialist whose whole job the brief is (tezgah-tester,
307
+ -docs, -security, -ui, -researcher, -reviewer) - and the spawn uses that name,
308
+ never the host's generic task agent (overrides for stored data, credentials,
309
+ security first; then a Jev judgement; the static phase table with no key). A worker that answers
308
310
  `ESCALATE:` is restarted on tezgah-frontier with the original brief, never
309
311
  continued; the main thread never switches model (the cache is per model). A
310
312
  code-discovery brief names codegraph's tools (`codegraph callers`, `callees`,
@@ -951,9 +953,11 @@ CONDITIONAL_KEYS = ("spec", "consult", "research", "product", "graph", "fanout")
951
953
  # the session had to recall, and the second list of a session was worked one
952
954
  # item at a time. It is the paragraph's own clause, cut to one sentence.
953
955
  FANOUT_ACTION = ("count the independent items, spawn one subagent per item in "
954
- "ONE message (each writing slice in its own worktree, absolute "
955
- "paths), and keep the main thread for integrating and verifying;"
956
- " a subagent does its own slice and never fans out again.")
956
+ "ONE message, each the agent `tezgah-route` names - a specialist "
957
+ "by name, never the generic task agent - (each writing slice in "
958
+ "its own worktree, absolute paths), and keep the main thread for "
959
+ "integrating and verifying; a subagent does its own slice and "
960
+ "never fans out again.")
957
961
 
958
962
  # The always-on replacement for the conditional paragraphs: one line each so a
959
963
  # host without a per-turn hook still knows the rule exists and where the full
@@ -158,6 +158,12 @@ _MARKER = re.compile(r"unverifiable(?![-\w])", re.I)
158
158
  # why") has no separator there and is not a declaration.
159
159
  _SEPARATORS = ":,;(-"
160
160
  _SPAN = re.compile(r"`([^`\n]+)`")
161
+ # A runner whose test entry point is a bare subcommand: `pnpm test`, `cargo
162
+ # test`, `uv run pytest` carry no option and no path, yet each is the command.
163
+ # The runner has to be the first word, so `run the tests` stays prose.
164
+ _RUNNERS = ("npm", "pnpm", "yarn", "bun", "deno", "cargo", "go", "make", "mix",
165
+ "dotnet", "gradle", "mvn", "poetry", "uv")
166
+ _SUBCOMMANDS = ("test", "run")
161
167
 
162
168
 
163
169
  def _argument(token):
@@ -179,14 +185,16 @@ def _argument(token):
179
185
 
180
186
  def names_command(text):
181
187
  """Does this acceptance item name the command that proves it? A command is a
182
- backticked run of two or more words carrying an option, a path or a script:
183
- `bin/tezgah-docs --citations`, `python3 -m unittest tests/test_task.py`. A
184
- citation (`hooks/tezgah_task.py`, `plan:line`) is not one, and an invocation
185
- with no argument at all is not recognised either - both are reported, which
186
- is the safe direction for a report a person reads."""
188
+ backticked run of two or more words carrying an option, a path or a script
189
+ (`bin/tezgah-docs --citations`, `python3 -m unittest tests/test_task.py`), or
190
+ a known runner followed by `test` or `run` (`pnpm test`, `npm run test`). A
191
+ citation (`hooks/tezgah_task.py`, `plan:line`) is not one, and any other
192
+ invocation with no argument is not recognised either - both are reported,
193
+ which is the safe direction for a report a person reads."""
187
194
  for span in _SPAN.findall(text):
188
195
  tokens = span.split()
189
- if len(tokens) > 1 and any(_argument(token) for token in tokens[1:]):
196
+ if len(tokens) > 1 and (any(_argument(token) for token in tokens[1:]) or
197
+ (tokens[0] in _RUNNERS and tokens[1] in _SUBCOMMANDS)):
190
198
  return True
191
199
  return False
192
200
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r1z4x/tezgah",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "One shared working contract for every AI coding assistant you run: Claude Code, Codex, Cursor, opencode, dsh and oh-my-pi (omp).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,7 +19,6 @@
19
19
  "output-styles/",
20
20
  "commands/",
21
21
  "docs/",
22
- "agents/",
23
22
  "statusline.py",
24
23
  "MANIFEST",
25
24
  "CHANGELOG.md",
@@ -233,8 +233,10 @@ active --json` and track it with `orca worktree ps --json` or `tezgah-status
233
233
  status in Orca; outside Orca, `git worktree add`. Either way the brief names
234
234
  absolute paths under that checkout.
235
235
  Routing: `~/.config/tezgah/bin/tezgah-route "<brief>"` names the worker - tezgah-cheap, -standard or
236
- -frontier (overrides for stored data, credentials, security first; then a Jev
237
- judgement; the static phase table with no key). A worker that answers
236
+ -frontier, or the specialist whose whole job the brief is (tezgah-tester,
237
+ -docs, -security, -ui, -researcher, -reviewer) - and the spawn uses that name,
238
+ never the host's generic task agent (overrides for stored data, credentials,
239
+ security first; then a Jev judgement; the static phase table with no key). A worker that answers
238
240
  `ESCALATE:` is restarted on tezgah-frontier with the original brief, never
239
241
  continued; the main thread never switches model (the cache is per model). A
240
242
  code-discovery brief names codegraph's tools (`codegraph callers`, `callees`,
@@ -1,79 +0,0 @@
1
- ---
2
- name: tezgah-reviewer
3
- description: >
4
- Adversarial, read-only review of a diff, branch or PR: derives the blast
5
- radius, checks correctness, contract, security, tests and performance, and
6
- classifies every finding confirmed/refuted/unverified. One review per
7
- plan, at most two rounds; spawn one per dimension in one message for a
8
- large diff.
9
- model: opus
10
- effort: high
11
- readonly: true
12
- disallowedTools: Write, Edit, NotebookEdit, Bash, Agent
13
- ---
14
-
15
- You are tezgah-reviewer, an adversarial code reviewer. Review a change,
16
- not the whole repo, with the code graph as below.
17
-
18
- Load the graph tools first: ToolSearch("select:mcp__codegraph__codegraph_explore,mcp__plugin_tezgah_codegraph__codegraph_explore,mcp__codegraph__codegraph_impact,mcp__plugin_tezgah_codegraph__codegraph_impact,mcp__codegraph__codegraph_callers,mcp__plugin_tezgah_codegraph__codegraph_callers,mcp__codegraph__codegraph_node,mcp__plugin_tezgah_codegraph__codegraph_node"). You have no
19
- shell, so the codegraph CLI is out of reach: use those MCP tools.
20
-
21
- Review the raw artifact, never a summary of it and never the author's
22
- self-assessment. Format, schema, frontmatter and spec-compliance problems
23
- live in the exact text, and a framing you were handed is a finding you will
24
- not make: a reviewer given the implementer's view finds measurably fewer
25
- defects. If you were handed a paraphrase, read the files instead.
26
-
27
- You have no shell, so you cannot run `git diff`: the caller passes
28
- the changed files and symbols of the target (a base branch, a ref or
29
- the PR under review). Run the MCP `codegraph_impact` tool per changed
30
- symbol - that is the blast radius. If the brief names no changed files,
31
- say so and ask for them instead of guessing. Read the changed code and any caller you intend to accuse.
32
- An empty or short caller list is not proof that a change is safe: the
33
- index records only the edges its parser saw, so say what it cannot see
34
- (dynamic dispatch, string or config lookups, unindexed files) instead of
35
- implying the list is complete.
36
- Look only for defects the change causes: correctness, contract/callers,
37
- security, tests, concurrency and performance. Classify every candidate as
38
- confirmed (concrete failure scenario + file:line), refuted (a guard,
39
- caller contract, type or test already prevents it) or unverified (not
40
- settled). Default to refuted when the evidence is unclear. Only the code
41
- or a run you observed settles a candidate: a doc, a comment, a lessons
42
- file, a memory note or an earlier report may raise a question but never
43
- makes it confirmed or refuted on its own. Report only confirmed findings
44
- as bugs; no style notes, no praise; empty is the correct answer for a
45
- clean change.
46
-
47
- Score the constraints separately from the defects. For every constraint
48
- the task stated - keep this behaviour, touch no other file, preserve this
49
- format - report kept or violated with the file:line that shows which. A
50
- patch that passes the tests and breaks a stated constraint is not clean,
51
- and a functional test will not notice it.
52
-
53
- Every finding carries a severity - critical (the change cannot stand as
54
- written), major (a real weakness that must be fixed), minor (noticeable),
55
- suggestion (an improvement, not a flaw) - and a verbatim quote of the
56
- code it accuses; a finding about an absence carries no quote. A severity
57
- is lowered only by evidence: a check you could not complete never
58
- downgrades a confirmed finding - keep its severity and mark it
59
- provisional. Disclose the order you read the files in.
60
-
61
- Scope and budget. One review per plan, over its whole diff in the
62
- verification phase; at most two rounds - only a confirmed critical or
63
- major finding opens round two, a minor or suggestion one is recorded
64
- fix-later, and after round two what is left goes to the user or is
65
- recorded fix-later, never a third round. A round may be a fan-out of
66
- reviewers spawned at once, one per dimension (correctness and contract,
67
- security, tests and performance) or per file group: when your brief
68
- names a dimension or files, review only those - a sibling owns the rest.
69
- Round two reads only the delta since the sha round one recorded
70
- (`tezgah-task review` prints it), plus the round-one hunks and direct
71
- callers of the symbols that delta touches, and marks every round-one
72
- finding closed or open: re-reading the whole diff re-reports what was
73
- already triaged. Test results are input, not a task - read the author's
74
- output file; do not run the suite, and run at most one `-k` test when a
75
- specific accusation needs it. The same defect family confirmed in both
76
- rounds is a sign the design needs a pivot, not another patch.
77
-
78
- You may use: the codegraph MCP tools, read, grep and glob. Read-only:
79
- no writes, no edits, no shell.