@r1z4x/tezgah 1.4.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +120 -1
  2. package/MANIFEST +0 -1
  3. package/README.md +6 -4
  4. package/bin/tezgah-docs +3 -3
  5. package/bin/tezgah-route +12 -6
  6. package/bin/tezgah-setup +67 -24
  7. package/bin/tezgah-status +3 -1
  8. package/bin/tezgah-task +132 -39
  9. package/bin/tezgah-taste +49 -31
  10. package/bin/tezgah-triage +12 -7
  11. package/docs/architecture.md +1 -1
  12. package/docs/clarity-baseline.json +7 -7
  13. package/docs/contract.md +21 -11
  14. package/docs/environment.md +3 -1
  15. package/docs/evidence.md +4 -3
  16. package/docs/glossary.md +1 -1
  17. package/docs/hosts.md +9 -0
  18. package/docs/judge.md +95 -27
  19. package/docs/layers.md +1 -1
  20. package/docs/models.md +17 -7
  21. package/docs/operations.md +23 -13
  22. package/docs/skills.md +1 -1
  23. package/docs/testing.md +1 -1
  24. package/docs/vision.md +10 -5
  25. package/hooks/tezgah_agents.py +149 -105
  26. package/hooks/tezgah_context.py +42 -7
  27. package/hooks/tezgah_judge.py +252 -79
  28. package/hooks/tezgah_models.py +50 -3
  29. package/hooks/tezgah_paths.py +180 -27
  30. package/hooks/tezgah_policy.py +41 -10
  31. package/hooks/tezgah_replay.py +26 -15
  32. package/hooks/tezgah_task.py +160 -46
  33. package/hooks/tezgah_taste.py +2 -2
  34. package/hooks/tezgah_taste_ledger.py +13 -4
  35. package/hosts/codex/hook.py +5 -0
  36. package/package.json +1 -2
  37. package/skills/harness/SKILL.md +4 -0
  38. package/skills/plan-add/SKILL.md +23 -7
  39. package/skills/plan-add/render_table.py +3 -2
  40. package/skills/plan-status/SKILL.md +12 -4
  41. package/skills/plan-sync/SKILL.md +19 -9
  42. package/skills/tezgah-contract/SKILL.md +29 -14
  43. package/agents/tezgah-reviewer.md +0 -70
@@ -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))
@@ -206,15 +219,22 @@ def _reviewer_body(host):
206
219
  "is lowered only by evidence: a check you could not complete never\n"
207
220
  "downgrades a confirmed finding - keep its severity and mark it\n"
208
221
  "provisional. Disclose the order you read the files in.\n\n"
209
- "Scope and budget. Every round that confirms a defect is followed by a\n"
210
- "round over that fix's delta; the review ends when a round confirms none.\n"
211
- "A later round reads only the delta since the sha the previous round\n"
212
- "recorded (`tezgah-task review` prints it): re-reading the whole diff\n"
213
- "re-reports what was already triaged. Test results are input, not a task\n"
214
- "- read the author's output file; do not run the suite, and run at most one\n"
215
- "`-k` test when a specific accusation needs it. The same defect family\n"
216
- "confirmed three times is a sign the design needs a pivot, not another\n"
217
- "patch.\n\n"
222
+ "Scope and budget. One review per plan, over its whole diff in the\n"
223
+ "verification phase; at most two rounds - only a confirmed critical or\n"
224
+ "major finding opens round two, a minor or suggestion one is recorded\n"
225
+ "fix-later, and after round two what is left goes to the user or is\n"
226
+ "recorded fix-later, never a third round. A round may be a fan-out of\n"
227
+ "reviewers spawned at once, one per dimension (correctness and contract,\n"
228
+ "security, tests and performance) or per file group: when your brief\n"
229
+ "names a dimension or files, review only those - a sibling owns the rest.\n"
230
+ "Round two reads only the delta since the sha round one recorded\n"
231
+ "(`tezgah-task review` prints it), plus the round-one hunks and direct\n"
232
+ "callers of the symbols that delta touches, and marks every round-one\n"
233
+ "finding closed or open: re-reading the whole diff re-reports what was\n"
234
+ "already triaged. Test results are input, not a task - read the author's\n"
235
+ "output file; do not run the suite, and run at most one `-k` test when a\n"
236
+ "specific accusation needs it. The same defect family confirmed in both\n"
237
+ "rounds is a sign the design needs a pivot, not another patch.\n\n"
218
238
  "%s" % (_graph_howto(host), _blast_radius(host), EMPTY_IS_NOT_PROOF,
219
239
  _may_use(host)))
220
240
 
@@ -343,7 +363,8 @@ ROLES = (
343
363
  ("tezgah-reviewer",
344
364
  "Adversarial, read-only review of a diff, branch or PR: derives the blast "
345
365
  "radius, checks correctness, contract, security, tests and performance, and "
346
- "classifies every finding confirmed/refuted/unverified.",
366
+ "classifies every finding confirmed/refuted/unverified. One review per plan, "
367
+ "at most two rounds; spawn one per dimension in one message for a large diff.",
347
368
  lambda infra: infra["caps"]["graph"], _reviewer_body, True),
348
369
  ("tezgah-cheap",
349
370
  "Mechanical, fully specified edits on the cheapest model tier: renames, "
@@ -389,7 +410,10 @@ def _orch_body(names):
389
410
  "verify; you delegate execution and read the evidence back. Available\n"
390
411
  "specialists: %s.\n\n"
391
412
  "Fan out by default: when the request or your todo list holds two or more\n"
392
- "independent items, spawn one subagent per item in ONE message. Give\n"
413
+ "independent items, count them and spawn one subagent per item in ONE\n"
414
+ "message - at every phase: a plan's tasks, its acceptance commands and the\n"
415
+ "sources of a survey are items too, and checks that share no state run as\n"
416
+ "tool calls in one message, never a queue. Give\n"
393
417
  "every writing slice its own branch and checkout - `orca worktree create\n"
394
418
  "--name <slug> --parent-worktree active --json` when the session runs\n"
395
419
  "inside Orca, else `git worktree add -b <branch> <absolute path>` - and\n"
@@ -429,10 +453,8 @@ def _claude_model(model):
429
453
 
430
454
  def _model_lines(name, family, model_fmt, effort_fmt):
431
455
  """The model (and effort) lines the table gives this agent on a family;
432
- `model: inherit` on Claude/Cursor for an agent with no slot. Cursor reads
433
- this same file but wants its own ids and the effort inside the id
434
- (`claude-opus-5[effort=high]`, cursor.com/docs/subagents) - unverified for
435
- 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)."""
436
458
  picked = tm.pick(name, family)
437
459
  if not picked:
438
460
  return ["model: inherit"] if family == "anthropic" else []
@@ -451,35 +473,34 @@ def _fold(desc, width=74):
451
473
 
452
474
 
453
475
  def render_md(name, description, readonly, body):
454
- """Claude Code / Cursor markdown. Cursor ignores unknown keys and honours
455
- `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."""
456
481
  lines = ["---", MARKER + " (manifest %s)" % manifest_sha(),
457
- "name: %s" % name, "description: >"]
458
- lines += _fold(description)
482
+ "name: %s" % name, "description: %s" % " ".join(description.split())]
459
483
  lines += _model_lines(name, "anthropic", "model: %s", "effort: %s")
460
484
  if readonly:
461
- # `disallowedTools` is Claude's; `readonly` is Cursor's. Both parsers
462
- # ignore the other's field.
463
485
  lines.append("readonly: true")
464
486
  lines.append("disallowedTools: Write, Edit, NotebookEdit, Bash, Agent")
465
487
  lines.append("---")
466
488
  return "\n".join(lines) + "\n\n" + body.strip() + "\n"
467
489
 
468
490
 
469
- def plugin_agents():
470
- """{filename: text} for the plugin's own tracked `agents/` dir.
471
-
472
- The graph roles' Claude render, from the same body the per-repo files get,
473
- so the shipped copy cannot drift from the generated one. The MARKER line is
474
- dropped: the copy is tracked, never swept by `_remove_stale`, and its
475
- manifest sha would turn every edit of this module into a stale file.
476
- `tezgah-setup --write-plugin-agents` writes it; a test pins it."""
477
- out = {}
478
- for name, desc, _cap, body, readonly in ROLES:
479
- if name in GRAPH_ROLES:
480
- lines = render_md(name, desc, readonly, body("claude")).split("\n")
481
- out[name + ".md"] = "\n".join(lines[:1] + lines[2:])
482
- 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"
483
504
 
484
505
 
485
506
  def render_md_opencode(name, description, readonly, body):
@@ -518,13 +539,19 @@ def render_md_omp(name, description, body, tools=("read", "grep", "glob", "bash"
518
539
  return "\n".join(lines) + "\n\n" + body.strip() + "\n"
519
540
 
520
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
+
521
551
  def omp_user_agents(root="~"):
522
552
  """{filename: text} for omp's user agent dir (~/.omp/agent/agents); empty
523
553
  under `agents-off`, so the installer's sweep removes the files."""
524
- if off("agents-off"):
525
- return {}
526
- infra = detect_infra(os.path.expanduser(root))
527
- active = [r for r in ROLES if r[2](infra)]
554
+ active = _active_roles(root)
528
555
  names = [r[0] for r in active]
529
556
  if not names:
530
557
  return {}
@@ -539,20 +566,50 @@ def omp_user_agents(root="~"):
539
566
  return out
540
567
 
541
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
+
542
593
  def steering(root, host=None):
543
594
  """One line naming the generated specialists this host can spawn, or None.
544
595
 
545
596
  Nothing else in the contract points the main agent at them, so a model
546
597
  reached for the host's generic explorer instead. Existence is read from
547
598
  disk, not from the capabilities: a specialist is named only when its file
548
- is there for the host to load - omp's user agent dir on omp, the repo's
549
- generated dirs everywhere else. A repo's `.no-graph` turns the graph off
550
- 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
551
603
  exists."""
552
604
  if host == "omp":
553
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])]
554
610
  else:
555
- 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")])
556
613
  have = {os.path.splitext(os.path.basename(p))[0]
557
614
  for d in dirs for p in glob.glob(os.path.join(d, "tezgah-*.*"))}
558
615
  if os.path.exists(os.path.join(root, ".no-graph")):
@@ -645,14 +702,18 @@ def _write_if_changed(path, text):
645
702
  return True
646
703
 
647
704
 
648
- def _remove_stale(directory, wanted):
649
- """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 = []
650
709
  for path in glob.glob(os.path.join(directory, "tezgah-*.*")):
651
710
  if os.path.basename(path) not in wanted and _is_managed(_read(path)):
652
711
  try:
653
712
  os.remove(path)
713
+ removed.append(path)
654
714
  except OSError:
655
715
  pass
716
+ return removed
656
717
 
657
718
 
658
719
  BLOCK_BEGIN = ("# tezgah: generated agents (managed; removed by "
@@ -785,66 +846,49 @@ def sync_root(root, report_steady=False):
785
846
  return None
786
847
  disabled = off("agents-off")
787
848
  infra = detect_infra(root)
788
- 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)
789
852
  active = [] if disabled else [r for r in ROLES if r[2](infra)]
790
853
  names = [r[0] for r in active]
791
854
 
792
- # markdown: one dir serves Claude and Cursor (Cursor reads .claude/agents/)
793
- 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
794
856
  oc_dir = os.path.join(root, HOST_DIRS["opencode"]) if "opencode" in hosts else None
795
- codex_dir = os.path.join(root, HOST_DIRS["codex"]) if "codex" in hosts else None
796
857
 
797
858
  written, removed = 0, 0
798
859
  paths = []
860
+ wanted = {h: set() for h in HOST_DIRS}
799
861
 
800
- def emit(directory, wanted, name, text):
862
+ def emit(host, name, text):
801
863
  nonlocal written
802
- wanted.add(name)
803
- path = os.path.join(directory, name)
864
+ wanted[host].add(name)
865
+ path = os.path.join(root, HOST_DIRS[host], name)
804
866
  if _write_if_changed(path, text):
805
867
  written += 1
806
868
  paths.append(path)
807
869
 
808
- wanted_md, wanted_oc, wanted_codex = set(), set(), set()
809
-
810
- def sweep(directory, wanted):
811
- nonlocal removed
812
- before = set(os.path.basename(p) for p in glob.glob(os.path.join(directory, "tezgah-*.*")))
813
- _remove_stale(directory, wanted)
814
- after = set(os.path.basename(p) for p in glob.glob(os.path.join(directory, "tezgah-*.*")))
815
- removed += len(before - after)
816
-
817
- 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:
818
873
  for name, desc, _cap, body, readonly in active:
819
- emit(md_dir, wanted_md, name + ".md", render_md(name, desc, readonly, body("claude")))
820
- 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")))
821
876
 
822
877
  if oc_dir and names: # opencode
823
878
  for name, desc, _cap, body, readonly in active:
824
- emit(oc_dir, wanted_oc, name + ".md",
879
+ emit("opencode", name + ".md",
825
880
  render_md_opencode(name, desc, readonly, body("opencode")))
826
- emit(oc_dir, wanted_oc, "tezgah-orchestrator.md", render_orch_md_opencode(names))
827
-
828
- if codex_dir and names: # Codex (no orchestrator agent; the main thread orchestrates)
829
- for name, desc, _cap, body, readonly in active:
830
- emit(codex_dir, wanted_codex, name + ".toml",
831
- render_toml(name, desc, readonly, body("codex")))
881
+ emit("opencode", "tezgah-orchestrator.md", render_orch_md_opencode(names))
832
882
 
833
- # The sweep runs over every dir this module can write, not only the selected
834
- # ones: a host dropped from the config, or a capability that disappeared,
835
- # must not leave tezgah agents behind for the host to keep loading.
836
- sweep(os.path.join(root, HOST_DIRS["claude"]), wanted_md)
837
- sweep(os.path.join(root, HOST_DIRS["opencode"]), wanted_oc)
838
- 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]))
839
889
 
840
890
  if paths:
841
- dirs = set()
842
- if md_dir:
843
- dirs.add(HOST_DIRS["claude"])
844
- if oc_dir:
845
- dirs.add(HOST_DIRS["opencode"])
846
- if codex_dir:
847
- dirs.add(HOST_DIRS["codex"])
891
+ dirs = {HOST_DIRS[h] for h in ("cursor", "opencode") if wanted[h]}
848
892
  ex = ensure_exclude(root, dirs)
849
893
  if ex:
850
894
  paths.append(ex)
@@ -857,7 +901,7 @@ def sync_root(root, report_steady=False):
857
901
  % (len(names), ", %d removed" % removed if removed else ""))
858
902
  if removed:
859
903
  return "%d stale agent file(s) removed" % removed
860
- 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):
861
905
  return "%d agent(s) current" % len(names)
862
906
  return None
863
907
 
@@ -26,8 +26,9 @@ from tezgah_integrity import (STEP_KINDS, _heredocs, _path as _ledger_path,
26
26
  UNTRUSTED_CHANNEL)
27
27
  from tezgah_lessons import (lesson_key, lines as lesson_lines,
28
28
  tainted as tainted_lessons)
29
- from tezgah_policy import (CONDITIONAL_KEYS, CORE, POINTERS, PROMPT_REMINDER,
30
- REPLY_LANG_TEXT, open_lines_note, pony_level_line)
29
+ from tezgah_policy import (CONDITIONAL_KEYS, CORE, FANOUT_ACTION, POINTERS,
30
+ PROMPT_REMINDER, REPLY_LANG_TEXT, open_lines_note,
31
+ pony_level_line)
31
32
  from tezgah_paths import (CACHE, REPO_MARKS, SWITCHES, ai_research_dir, armed,
32
33
  cache_dir, codegraph_bin, consult_options,
33
34
  ensure_workspace, fallback_cache, have_judge_key, off,
@@ -106,6 +107,21 @@ def _tr_ui_ask(word):
106
107
  return (r"(?:\b(?:%s)\w*[^.!?\n]{0,40}\b(?:%s))" % (TR_UI_OBJECTS, word))
107
108
 
108
109
 
110
+ # The fan-out hint's parts (PROMPT_HINTS["fanout"]). A list item's marker:
111
+ # `1.`, `1)`, `1 -`, `1:`, `a)`, or a `-`/`*`/`+`/`•` bullet, then its text.
112
+ _FAN_ITEM = r"(?:\d+[ \t]*[.):\u2013-]|[a-z]\)|[-*+\u2022])[ \t]+\S"
113
+ # A file a prompt names: a path with a directory, or a name with an extension.
114
+ # Anchored at a token start: unanchored, a long run of word characters made
115
+ # every position a start and the search quadratic (100 KB took minutes).
116
+ _FAN_FILE = (r"(?<![\w./-])[`'\"]?(?:(?:[\w.-]+/)+[\w.-]*\w|"
117
+ r"\w[\w-]*\.[a-z]{1,4}\b)[`'\"]?")
118
+ # The imperative that opens an English clause and closes a Turkish one.
119
+ _FAN_VERB_EN = (r"(?:fix|add|update|write|remove|delete|rename|move|refactor|"
120
+ r"document|bump|create|implement|migrate|replace)")
121
+ _FAN_VERB_TR = (r"(?:düzelt|güncelle|ekle|yaz|sil|kaldır|taşı|bitir|tamamla|"
122
+ r"incele|oluştur|değiştir)")
123
+
124
+
109
125
  PROMPT_HINTS = (
110
126
  ("spec", r"\b(normal (user )?behaviou?r|clean ui|nicer|more intuitive|"
111
127
  r"un?professional|polish(ed)?|improve the (ui|ux)|make it (better|"
@@ -340,9 +356,22 @@ PROMPT_HINTS = (
340
356
  # had to say "do everything with subagents and in parallel" on every task
341
357
  # because nothing armed the fan-out rule from the shape of the request.
342
358
  # `parallelize` alone is code work, so only the delegation phrasings count.
343
- ("fanout", r"(?ms:^[ \t]*\d+[.)][ \t]+\S.*?^[ \t]*\d+[.)][ \t]+\S)|"
344
- r"(?ms:^[ \t]*[-*•][ \t]+\S.*?^[ \t]*[-*•][ \t]+\S)|"
345
- r"\b(?:in parallel|paralel\w*|sub-?agents?|alt ?ajan\w*|"
359
+ # The owner's own shapes armed nothing until 2026-10-09: `1 - `, `1- `,
360
+ # `1:`, `a)`, `+` items, an inline `1. ... 2. ...`, "subagentlerle", three
361
+ # files named as one list, "First, ... Second, ...", and three imperative
362
+ # clauses in one sentence (clause-initial in English, clause-final in
363
+ # Turkish). A timestamp, a range and a traceback stay out (the corpus).
364
+ ("fanout", r"(?ms:^[ \t]*" + _FAN_ITEM + r".*?^[ \t]*" + _FAN_ITEM + r")|"
365
+ r"(?<![\w.])1[ \t]*[.)-][ \t]+\S[^\n]*?(?<![\w.])2[ \t]*[.)-][ \t]+\S|"
366
+ + _FAN_FILE + r"(?:[ \t]*(?:,[ \t]*(?:(?:and|ve)[ \t]+)?|"
367
+ r"(?:and|ve|&)[ \t]+)" + _FAN_FILE + r"){2,}|"
368
+ r"(?s:\bfirst(?:ly)?[,:].*?\bsecond(?:ly)?[,:])|"
369
+ r"(?s:\bbirinci(?:si)?[,:].*?\bikinci(?:si)?[,:])|"
370
+ r"(?m:(?:(?:^|[,;]|\band\b)[ \t]*(?:and[ \t]+)?" + _FAN_VERB_EN
371
+ + r"\b[^\n]*?){3})|"
372
+ r"(?m:(?:\b" + _FAN_VERB_TR + r"\w{0,4}[ \t]*(?:[,;.]|\bve\b|$)"
373
+ r"[^\n]*?){3})|"
374
+ r"\b(?:in parallel|paralel\w*|sub-?agent\w*|alt ?ajan\w*|"
346
375
  r"worktrees?|fan[- ]?out|orchestrat\w*|orkestra\w*)\b"),
347
376
  )
348
377
 
@@ -661,7 +690,8 @@ def autoindex(root):
661
690
 
662
691
 
663
692
  def sync_agents(root):
664
- """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."""
665
695
  try:
666
696
  from tezgah_agents import sync_root
667
697
  return sync_root(root)
@@ -1094,8 +1124,13 @@ ARMED_RESURFACE = 20
1094
1124
  def armed_again(key):
1095
1125
  """The one line a matching prompt pays for a paragraph this session was
1096
1126
  already shown. The research rule keeps its `{OPEN_LINES}` slot: the open
1097
- lines are a fact about the repo now, not rule text the session holds."""
1127
+ lines are a fact about the repo now, not rule text the session holds. The
1128
+ fan-out rule keeps its action (`FANOUT_ACTION`): the rule is a thing to do
1129
+ on this turn's list, and a bare reminder that it exists was not done."""
1098
1130
  label = dict(CORE_RULES)[key]
1131
+ if key == "fanout":
1132
+ return ("%s Armed again: %s The full rule is in the `tezgah-contract` "
1133
+ "skill." % (label, FANOUT_ACTION))
1099
1134
  return ("%s Armed again: the full rule was given earlier this session and "
1100
1135
  "is in the `tezgah-contract` skill.%s"
1101
1136
  % (label, "{OPEN_LINES}" if key == "research" else ""))