@r1z4x/tezgah 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/MANIFEST +6 -1
  3. package/README.md +6 -4
  4. package/bin/consult +13 -10
  5. package/bin/tezgah-docs +1 -1
  6. package/bin/tezgah-research +53 -3
  7. package/bin/tezgah-route +8 -5
  8. package/bin/tezgah-setup +45 -25
  9. package/bin/tezgah-task +194 -41
  10. package/docs/architecture.md +1 -1
  11. package/docs/citations-baseline.json +1 -1
  12. package/docs/clarity-baseline.json +4 -4
  13. package/docs/contract.md +4 -1
  14. package/docs/feature-audit.md +24 -3
  15. package/docs/gate.md +10 -11
  16. package/docs/hosts.md +9 -0
  17. package/docs/index.json +21 -7
  18. package/docs/judge.md +3 -1
  19. package/docs/layers.md +1 -1
  20. package/docs/models.md +17 -7
  21. package/docs/operations.md +0 -1
  22. package/docs/research.md +46 -0
  23. package/docs/skills.md +23 -5
  24. package/docs/testing.md +1 -1
  25. package/hooks/tezgah_agents.py +130 -96
  26. package/hooks/tezgah_components.py +2 -1
  27. package/hooks/tezgah_context.py +67 -15
  28. package/hooks/tezgah_gate.py +62 -76
  29. package/hooks/tezgah_judge.py +15 -8
  30. package/hooks/tezgah_models.py +50 -3
  31. package/hooks/tezgah_paths.py +26 -11
  32. package/hooks/tezgah_policy.py +47 -19
  33. package/hooks/tezgah_product.py +107 -0
  34. package/hooks/tezgah_research.py +3 -3
  35. package/hooks/tezgah_skill_pick.py +58 -17
  36. package/hooks/tezgah_task.py +16 -8
  37. package/hosts/opencode/plugins/tezgah.js +17 -37
  38. package/package.json +1 -2
  39. package/skills/analyze-app/SKILL.md +57 -0
  40. package/skills/design-contract/EVALS.md +24 -1
  41. package/skills/design-contract/SKILL.md +17 -1
  42. package/skills/design-contract/data-views.md +86 -0
  43. package/skills/feature-audit/SKILL.md +32 -3
  44. package/skills/plan-add/EVALS.md +8 -8
  45. package/skills/plan-add/SKILL.md +20 -21
  46. package/skills/plan-sync/EVALS.md +2 -2
  47. package/skills/plan-sync/SKILL.md +11 -7
  48. package/skills/pm-frameworks/SKILL.md +6 -0
  49. package/skills/product-analysis/EVALS.md +23 -1
  50. package/skills/product-analysis/SKILL.md +50 -11
  51. package/skills/product-flow/EVALS.md +81 -0
  52. package/skills/product-flow/SKILL.md +91 -0
  53. package/skills/research/SKILL.md +10 -1
  54. package/skills/tezgah-contract/SKILL.md +67 -41
  55. package/agents/tezgah-reviewer.md +0 -79
@@ -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))
@@ -209,8 +222,9 @@ def _reviewer_body(host):
209
222
  "Scope and budget. One review per plan, over its whole diff in the\n"
210
223
  "verification phase; at most two rounds - only a confirmed critical or\n"
211
224
  "major finding opens round two, a minor or suggestion one is recorded\n"
212
- "fix-later, and after round two what is left goes to the user or is\n"
213
- "recorded fix-later, never a third round. A round may be a fan-out of\n"
225
+ "fix-later, and after round two what is left is fixed by the session\n"
226
+ "and closed with `--resolved`, or recorded fix-later, never a third\n"
227
+ "round. A round may be a fan-out of\n"
214
228
  "reviewers spawned at once, one per dimension (correctness and contract,\n"
215
229
  "security, tests and performance) or per file group: when your brief\n"
216
230
  "names a dimension or files, review only those - a sibling owns the rest.\n"
@@ -440,10 +454,8 @@ def _claude_model(model):
440
454
 
441
455
  def _model_lines(name, family, model_fmt, effort_fmt):
442
456
  """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."""
457
+ `model: inherit` on Claude for an agent with no slot. Cursor gets
458
+ `model: inherit` always (render_md_cursor)."""
447
459
  picked = tm.pick(name, family)
448
460
  if not picked:
449
461
  return ["model: inherit"] if family == "anthropic" else []
@@ -462,35 +474,34 @@ def _fold(desc, width=74):
462
474
 
463
475
 
464
476
  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."""
477
+ """Claude Code markdown (`~/.claude/agents`), which the Cursor editor also
478
+ reads (cursor.com/docs/subagents). The description is one line: Cursor's
479
+ line parser reads a folded `>` block as ">", and Claude's parser re-quotes
480
+ a plain value YAML rejects (a `: ` inside it). Claude denies the write/shell
481
+ tools explicitly; `readonly` is Cursor's key, which Claude ignores."""
467
482
  lines = ["---", MARKER + " (manifest %s)" % manifest_sha(),
468
- "name: %s" % name, "description: >"]
469
- lines += _fold(description)
483
+ "name: %s" % name, "description: %s" % " ".join(description.split())]
470
484
  lines += _model_lines(name, "anthropic", "model: %s", "effort: %s")
471
485
  if readonly:
472
- # `disallowedTools` is Claude's; `readonly` is Cursor's. Both parsers
473
- # ignore the other's field.
474
486
  lines.append("readonly: true")
475
487
  lines.append("disallowedTools: Write, Edit, NotebookEdit, Bash, Agent")
476
488
  lines.append("---")
477
489
  return "\n".join(lines) + "\n\n" + body.strip() + "\n"
478
490
 
479
491
 
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
492
+ def render_md_cursor(name, description, readonly, body):
493
+ """Cursor `.cursor/agents/*.md`. cursor-agent splits each frontmatter line
494
+ at its first ':' (no YAML), passes `model` through as a raw id and has no
495
+ `effort` key, so: a one-line description, `model: inherit` (the only id
496
+ portable across Cursor accounts), `readonly`, and no `tools` line - its
497
+ comma split would cut a Claude `Agent(a, b)` list into broken names."""
498
+ lines = ["---", MARKER + " (manifest %s)" % manifest_sha(),
499
+ "name: %s" % name, "description: %s" % " ".join(description.split()),
500
+ "model: inherit"]
501
+ if readonly:
502
+ lines.append("readonly: true")
503
+ lines.append("---")
504
+ return "\n".join(lines) + "\n\n" + body.strip() + "\n"
494
505
 
495
506
 
496
507
  def render_md_opencode(name, description, readonly, body):
@@ -529,13 +540,19 @@ def render_md_omp(name, description, body, tools=("read", "grep", "glob", "bash"
529
540
  return "\n".join(lines) + "\n\n" + body.strip() + "\n"
530
541
 
531
542
 
543
+ def _active_roles(root):
544
+ """The roles a user-level dir holds: none under `agents-off`, else those
545
+ whose capability the machine has (`~/.no-graph` counts as the repo's)."""
546
+ if off("agents-off"):
547
+ return []
548
+ infra = detect_infra(os.path.expanduser(root))
549
+ return [r for r in ROLES if r[2](infra)]
550
+
551
+
532
552
  def omp_user_agents(root="~"):
533
553
  """{filename: text} for omp's user agent dir (~/.omp/agent/agents); empty
534
554
  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)]
555
+ active = _active_roles(root)
539
556
  names = [r[0] for r in active]
540
557
  if not names:
541
558
  return {}
@@ -550,20 +567,50 @@ def omp_user_agents(root="~"):
550
567
  return out
551
568
 
552
569
 
570
+ def user_agent_dir(host):
571
+ """Where a USER_HOSTS host loads agents from in every session: Claude's
572
+ `~/.claude/agents`, Codex's `$CODEX_HOME/agents` (default ~/.codex)."""
573
+ return os.path.join(HOST_HOMES[host], "agents")
574
+
575
+
576
+ def user_agents(host, root="~"):
577
+ """{filename: text} for a USER_HOSTS host's user agent dir, each file
578
+ carrying MARKER so the installer sweeps only tezgah's; empty under
579
+ `agents-off`, so that sweep removes them. Codex gets no orchestrator: the
580
+ main thread orchestrates there."""
581
+ active = _active_roles(root)
582
+ names = [r[0] for r in active]
583
+ if not names:
584
+ return {}
585
+ if host == "codex":
586
+ return {name + ".toml": render_toml(name, desc, readonly, body("codex"))
587
+ for name, desc, _cap, body, readonly in active}
588
+ out = {name + ".md": render_md(name, desc, readonly, body("claude"))
589
+ for name, desc, _cap, body, readonly in active}
590
+ out["tezgah-orchestrator.md"] = render_orch_md(names)
591
+ return out
592
+
593
+
553
594
  def steering(root, host=None):
554
595
  """One line naming the generated specialists this host can spawn, or None.
555
596
 
556
597
  Nothing else in the contract points the main agent at them, so a model
557
598
  reached for the host's generic explorer instead. Existence is read from
558
599
  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
600
+ is there for the host to load - the user agent dir on omp, Claude and
601
+ Codex, the repo's own dir on Cursor and opencode, and all of them for a
602
+ host that does not say which it is. A repo's `.no-graph` turns the graph
603
+ off there, so a graph role is not named in it even when a user-level file
562
604
  exists."""
563
605
  if host == "omp":
564
606
  dirs = [os.path.join(HOME, ".omp", "agent", "agents")]
607
+ elif host in USER_HOSTS:
608
+ dirs = [user_agent_dir(host)]
609
+ elif host in HOST_DIRS:
610
+ dirs = [os.path.join(root, HOST_DIRS[host])]
565
611
  else:
566
- dirs = [os.path.join(root, d) for d in set(HOST_DIRS.values())]
612
+ dirs = ([user_agent_dir(h) for h in USER_HOSTS]
613
+ + [os.path.join(root, HOST_DIRS[h]) for h in ("cursor", "opencode")])
567
614
  have = {os.path.splitext(os.path.basename(p))[0]
568
615
  for d in dirs for p in glob.glob(os.path.join(d, "tezgah-*.*"))}
569
616
  if os.path.exists(os.path.join(root, ".no-graph")):
@@ -656,14 +703,18 @@ def _write_if_changed(path, text):
656
703
  return True
657
704
 
658
705
 
659
- def _remove_stale(directory, wanted):
660
- """Delete only tezgah-managed files no longer in the wanted set."""
706
+ def remove_stale(directory, wanted):
707
+ """Delete only tezgah-managed files no longer in the wanted set; returns
708
+ the paths removed."""
709
+ removed = []
661
710
  for path in glob.glob(os.path.join(directory, "tezgah-*.*")):
662
711
  if os.path.basename(path) not in wanted and _is_managed(_read(path)):
663
712
  try:
664
713
  os.remove(path)
714
+ removed.append(path)
665
715
  except OSError:
666
716
  pass
717
+ return removed
667
718
 
668
719
 
669
720
  BLOCK_BEGIN = ("# tezgah: generated agents (managed; removed by "
@@ -796,66 +847,49 @@ def sync_root(root, report_steady=False):
796
847
  return None
797
848
  disabled = off("agents-off")
798
849
  infra = detect_infra(root)
799
- hosts = set() if disabled else set(infra["hosts"])
850
+ # Claude's and Codex's roles are user-level (user_agents): only Cursor and
851
+ # opencode get a dir here
852
+ hosts = set() if disabled else set(infra["hosts"]) - set(USER_HOSTS)
800
853
  active = [] if disabled else [r for r in ROLES if r[2](infra)]
801
854
  names = [r[0] for r in active]
802
855
 
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
856
+ cursor_dir = os.path.join(root, HOST_DIRS["cursor"]) if "cursor" in hosts else None
805
857
  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
858
 
808
859
  written, removed = 0, 0
809
860
  paths = []
861
+ wanted = {h: set() for h in HOST_DIRS}
810
862
 
811
- def emit(directory, wanted, name, text):
863
+ def emit(host, name, text):
812
864
  nonlocal written
813
- wanted.add(name)
814
- path = os.path.join(directory, name)
865
+ wanted[host].add(name)
866
+ path = os.path.join(root, HOST_DIRS[host], name)
815
867
  if _write_if_changed(path, text):
816
868
  written += 1
817
869
  paths.append(path)
818
870
 
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
871
+ # Cursor gets no orchestrator: its files are subagents the main thread
872
+ # delegates to, and the orchestrator is a primary agent
873
+ if cursor_dir and names:
829
874
  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))
875
+ emit("cursor", name + ".md",
876
+ render_md_cursor(name, desc, readonly, body("cursor")))
832
877
 
833
878
  if oc_dir and names: # opencode
834
879
  for name, desc, _cap, body, readonly in active:
835
- emit(oc_dir, wanted_oc, name + ".md",
880
+ emit("opencode", name + ".md",
836
881
  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")))
882
+ emit("opencode", "tezgah-orchestrator.md", render_orch_md_opencode(names))
843
883
 
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)
884
+ # The sweep runs over every dir this module ever wrote, not only the
885
+ # selected ones: a host dropped from the config, a capability that
886
+ # disappeared, or a `.claude`/`.codex` copy from before the roles moved
887
+ # user-level must not stay behind for a host to keep loading.
888
+ for host, directory in HOST_DIRS.items():
889
+ removed += len(remove_stale(os.path.join(root, directory), wanted[host]))
850
890
 
851
891
  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"])
892
+ dirs = {HOST_DIRS[h] for h in ("cursor", "opencode") if wanted[h]}
859
893
  ex = ensure_exclude(root, dirs)
860
894
  if ex:
861
895
  paths.append(ex)
@@ -868,7 +902,7 @@ def sync_root(root, report_steady=False):
868
902
  % (len(names), ", %d removed" % removed if removed else ""))
869
903
  if removed:
870
904
  return "%d stale agent file(s) removed" % removed
871
- if report_steady and names and (md_dir or oc_dir or codex_dir):
905
+ if report_steady and names and (cursor_dir or oc_dir):
872
906
  return "%d agent(s) current" % len(names)
873
907
  return None
874
908
 
@@ -103,7 +103,8 @@ COMPONENTS = [
103
103
  "switch": "research-off", "editable": False, "revert": "git"},
104
104
  {"key": "product", "label": "product",
105
105
  "files": ["hooks/tezgah_policy.py", "skills/product-analysis/SKILL.md",
106
- "skills/feature-audit/SKILL.md"],
106
+ "skills/feature-audit/SKILL.md", "skills/product-flow/SKILL.md",
107
+ "hooks/tezgah_product.py"],
107
108
  "switch": "research-off", "editable": True, "revert": "git"},
108
109
  {"key": "attribution", "label": "attribution",
109
110
  "files": ["hooks/tezgah_policy.py", "hooks/tezgah_gate.py"],
@@ -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)
@@ -786,16 +787,16 @@ def task_line(task):
786
787
  and refuses a write the phase or the allowlist excludes, but a refusal
787
788
  costs a turn - so the phase rides every user turn, and the refusal is never
788
789
  the first the session hears of it. One line because it is paid every turn;
789
- the allowlist is the globs as written, and the phase is stated as the user's
790
- to move rather than as a command to run: the line used to print the CLI the
791
- user types, and E7b watched the session run that command five times in a row
792
- against a gate that refuses it every time. A line that names an act the gate
793
- refuses is an invitation to a loop."""
790
+ the allowlist is the globs as written, and the phase is stated as the
791
+ orchestrator's own move: the session runs `tezgah-task` itself when the
792
+ work reaches the next phase and closes the plan when it is done, never
793
+ handing those steps to the user."""
794
794
  paths = ", ".join(task.get("allowed_paths") or []) or "any path in the repo"
795
- return ("Active task %s is in phase `%s`; writes allowed on: %s. The phase "
796
- "belongs to the user - ask them for the one this work needs, and "
797
- "do not move it yourself." % (task.get("id"), task.get("phase"),
798
- paths))
795
+ return ("Active task %s is in phase `%s`; writes allowed on: %s. Move the "
796
+ "phase yourself with `tezgah-task phase <next>` when the work reaches "
797
+ "it, and `tezgah-task close %s done` when it is merged - never hand "
798
+ "these steps to the user." % (task.get("id"), task.get("phase"),
799
+ paths, task.get("id")))
799
800
 
800
801
 
801
802
  # --- the resume block: the live turn state a compact loses -------------------
@@ -1185,10 +1186,61 @@ def constraint_notice(cwd, session_id):
1185
1186
  return " ".join(render(subagent_core(core_for(cwd)[0])).split())
1186
1187
 
1187
1188
 
1189
+ # A data ask said by its output: a dashboard, a chart, a KPI, a metric to choose.
1190
+ # "Bu veriye uygun bir metric dashboard tasarla" armed nothing before plan 072.
1191
+ # These words are code words as often as product words (`dashboard.json`, a
1192
+ # metrics endpoint, a progress `gösterge`, the clipboard `pano`), and two rounds
1193
+ # of noun denylists inside the product regex kept missing the next code sense.
1194
+ # So the ask is judged per clause: it arms `product` only when no code marker -
1195
+ # a file name, an identifier, SQL, a build, test or runtime word - shares its
1196
+ # clause (decision 006: qualify the stem, never drop it).
1197
+ DATA_ASK = re.compile(
1198
+ r"\b(?:(?:design|create|make|build|sketch|propose|need|want)\w* (?:\w+ ){0,4}"
1199
+ r"(?:dashboard|charts?|visuali[sz]ation)|"
1200
+ r"(?:metri[ck]s?|kpis?|data|sales|revenue|veri\w*|satış\w*|gelir\w*) "
1201
+ r"(?:\w+ ){0,2}(?:dashboard|pano)|"
1202
+ r"(?:dashboard|gösterge panel)\w* (?:\w+ ){0,4}(?:tasarla|oluştur|hazırla|"
1203
+ r"yap|göster)\w*|"
1204
+ r"(?:what|which) (?:\w+ ){0,3}(?:metri[ck]s|kpis|charts?) (?:\w+ ){0,3}"
1205
+ r"(?:should|to|do|matter|would)|"
1206
+ r"what should (?:\w+ ){0,3}dashboard show|"
1207
+ r"(?:choose|pick|define|track|design)(?:s|ing)? (?:\w+ ){0,3}(?:metri[ck]s|kpis)|"
1208
+ r"(?:metri[ck]s|kpis) (?:should|to|do|would) (?:\w+ ){0,2}"
1209
+ r"(?:track|measure|use|watch)|success metrics?|"
1210
+ r"(?:suggest|recommend)\w* (?:\w+ ){0,3}charts?|öner\w* (?:\w+ ){0,3}grafi\w*|"
1211
+ r"hangi (?:metri[ck]\w*|kpi\w*|grafi\w*|göstergeleri?)|"
1212
+ r"(?:metri[ck]\w*|kpi\w*) (?:\w+ ){0,3}(?:tasarla|seç|tanımla|belirle|takip|izle)\w*|"
1213
+ r"gösterge panel\w*|visuali[sz]\w* (?:\w+ ){0,3}(?:data|numbers|veri\w*)|"
1214
+ r"data visuali[sz]ation|veri\w* (?:\w+ ){0,3}görselleştir\w*)")
1215
+ CODE_SENSE = re.compile(
1216
+ r"\w\.(?:py|tsx?|jsx?|json|go|rs|ya?ml|sql|java|rb|css|html)\b|\b\w+_\w+\b|"
1217
+ r"/metrics\b|\bselect\b.{0,200}?\bfrom\b|\b(?:tables?|columns?|schema|sql|queries|"
1218
+ r"query|bundle|webpack|vite|tests?|ci|counters?|registry|pipeline|backend|"
1219
+ r"handlers?|structs?|endpoints?|exporters?|export\w*|servers?|librar\w*|"
1220
+ r"packages?|modules?|database|index\w*|indeks\w*|"
1221
+ r"middleware|debugger|data (?:structure|flow)s?|clipboard|spinner|threads?|"
1222
+ r"commit|git|functions?|class|component|prop|hooks?|prometheus|grafana|"
1223
+ r"kibana|statsd|opentelemetry|config\w*|provision\w*|api|routes?|snake_case|"
1224
+ r"veri yapı\w*|fonksiyon\w*|bileşen\w*|modül\w*|dosya\w*|kopyala\w*|"
1225
+ r"seçici\w*|yükleme|ilerleme|paket\w*)\b")
1226
+
1227
+
1228
+ def _data_ask(low):
1229
+ """Does one clause of the prompt ask for a data view or a metric choice with
1230
+ no code marker beside it? A clause ends at `!`, `?`, `;`, a newline or a full
1231
+ stop followed by a space, so `dashboard.json` stays one clause."""
1232
+ return any(DATA_ASK.search(clause) and not CODE_SENSE.search(clause)
1233
+ for clause in re.split(r"[!?;\n]|\.\s", low))
1234
+
1235
+
1188
1236
  def classify_prompt(text):
1189
- """The conditional rule keys a prompt arms, from the shared hint table."""
1237
+ """The conditional rule keys a prompt arms, from the shared hint table, plus
1238
+ `product` for a data ask (DATA_ASK, gated per clause by CODE_SENSE)."""
1190
1239
  low = (text or "").lower()
1191
- return {key for key, pattern in PROMPT_HINTS if re.search(pattern, low)}
1240
+ keys = {key for key, pattern in PROMPT_HINTS if re.search(pattern, low)}
1241
+ if "product" not in keys and _data_ask(low):
1242
+ keys.add("product")
1243
+ return keys
1192
1244
 
1193
1245
 
1194
1246
  def prompt_text(payload):
@@ -1434,8 +1486,8 @@ DEFAULT_BUDGET = 13000
1434
1486
  # exists to prevent, not one it may cause.
1435
1487
  DROP_ORDER = ("knowledge", "worktrees", "lessons", "skill", "lessons_turn",
1436
1488
  "taste", "plans", "subagents", "steer", "orca", "consult", "research",
1437
- "research_broken", "graph", "offnote", "orchestrate", "index",
1438
- "resume", "scratch", "task", "delta", "pointer")
1489
+ "research_broken", "graph", "offnote", "orchestrate",
1490
+ "index", "resume", "scratch", "task", "delta", "pointer")
1439
1491
 
1440
1492
 
1441
1493
  def _first_item(text):
@@ -1576,7 +1628,7 @@ GATE_INACTIVE = (
1576
1628
  "tezgah gate inactive on this host: %d gated tool call(s) since this "
1577
1629
  "session's first turn and not one row from the gate, so the shortcut "
1578
1630
  "denials and the Stop check are not running. Say so before claiming a check "
1579
- "passed, and ask the user to run `tezgah-setup --report`.")
1631
+ "passed, run `tezgah-setup --report` yourself and quote its MISS rows.")
1580
1632
 
1581
1633
 
1582
1634
  def _iso_epoch(value):