@r1z4x/tezgah 1.0.0 → 1.1.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 (56) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/MANIFEST +8 -0
  3. package/bin/tezgah-agents +10 -1
  4. package/bin/tezgah-docs +1 -1
  5. package/bin/tezgah-doctor +11 -0
  6. package/bin/tezgah-research +31 -14
  7. package/bin/tezgah-setup +154 -29
  8. package/bin/tezgah-skill +59 -0
  9. package/bin/tezgah-skill.py +59 -0
  10. package/bin/tezgah-status +62 -1
  11. package/bin/tezgah-triage +5 -3
  12. package/docs/README.md +1 -0
  13. package/docs/architecture.md +5 -2
  14. package/docs/citations-baseline.json +1 -1
  15. package/docs/clarity-baseline.json +9 -9
  16. package/docs/contract.md +68 -26
  17. package/docs/environment.md +6 -3
  18. package/docs/evidence.md +7 -0
  19. package/docs/gate.md +13 -7
  20. package/docs/glossary.md +1 -1
  21. package/docs/hosts.md +48 -1
  22. package/docs/index.json +29 -1
  23. package/docs/judge.md +61 -55
  24. package/docs/models.md +14 -8
  25. package/docs/operations.md +10 -5
  26. package/docs/orca.md +126 -0
  27. package/docs/research.md +38 -3
  28. package/docs/skills.md +74 -0
  29. package/docs/status-line.md +3 -1
  30. package/docs/testing.md +33 -21
  31. package/docs/vision.md +12 -8
  32. package/hooks/projects-pretooluse.py +17 -5
  33. package/hooks/tezgah_agents.py +135 -13
  34. package/hooks/tezgah_components.py +3 -0
  35. package/hooks/tezgah_context.py +65 -22
  36. package/hooks/tezgah_gate.py +133 -17
  37. package/hooks/tezgah_integrity.py +60 -13
  38. package/hooks/tezgah_judge.py +239 -56
  39. package/hooks/tezgah_models.py +41 -5
  40. package/hooks/tezgah_orca.py +144 -0
  41. package/hooks/tezgah_paths.py +40 -6
  42. package/hooks/tezgah_policy.py +109 -35
  43. package/hooks/tezgah_research.py +104 -8
  44. package/hooks/tezgah_skill_pick.py +459 -8
  45. package/hooks/tezgah_stack.py +382 -0
  46. package/hooks/tezgah_stopspec.py +2 -2
  47. package/hosts/codex/hook.py +7 -1
  48. package/hosts/cursor/hook.py +7 -4
  49. package/hosts/omp/hook.py +12 -6
  50. package/hosts/omp/tezgah-hook.ts.in +20 -7
  51. package/hosts/opencode/plugins/tezgah.js +18 -9
  52. package/output-styles/tezgah.md +34 -19
  53. package/package.json +1 -1
  54. package/skills/i-have-adhd/SKILL.md +2 -2
  55. package/skills/research/SKILL.md +60 -17
  56. package/skills/tezgah-contract/SKILL.md +55 -13
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env python3
2
+ """The effective stack each host loads on this machine, and where it collides.
3
+
4
+ `tezgah-doctor --stack` prints it. For every host it lists, in the host's own
5
+ load order: the context files that exist, the skill dirs and which copy of a
6
+ skill name wins, the hook commands per event with their owner (tezgah, Orca, a
7
+ user's own), and the MCP servers. Then it flags what the owner otherwise finds
8
+ out by hand when omp, Claude Code, Codex, Cursor and opencode run side by side:
9
+
10
+ - skill-differs one host reads two skills of one name whose SKILL.md
11
+ differ; the copy that wins is named (first-wins hosts)
12
+ - skill-dup the same skill reached twice through two dirs one host reads
13
+ - skill-stale a shipped tezgah skill whose copy is not this tree's bytes
14
+ - rule-twice two context files one host loads both carry the tezgah block
15
+ - backup-in-scan a `.tezgah-bak` inside a dir the host enumerates
16
+ - hook-twice one host runs tezgah's same hook command twice for one event
17
+ - mcp-twice one host reads two definitions of one MCP server name
18
+
19
+ The load orders are the hosts' documented ones (docs/hosts.md#precedence), not
20
+ a probe of a running session: this module reads files and runs nothing.
21
+ Read-only, stdlib only.
22
+ """
23
+ import hashlib
24
+ import json
25
+ import os
26
+ import re
27
+
28
+ import tezgah_paths as tp
29
+
30
+ HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
31
+ TEZGAH_SKILLS = os.path.join(HERE, "skills")
32
+ BLOCK = "<!-- tezgah:start -->"
33
+ OWNERS = (("tezgah", "tezgah"), ("orca", "orca"), ("ORCA_", "orca"),
34
+ ("codegraph", "codegraph"))
35
+
36
+
37
+ def _read(path, limit=None):
38
+ try:
39
+ with open(path, encoding="utf-8", errors="replace") as fh:
40
+ return fh.read(limit) if limit else fh.read()
41
+ except OSError:
42
+ return ""
43
+
44
+
45
+ def _json(path):
46
+ try:
47
+ with open(path, encoding="utf-8") as fh:
48
+ data = json.load(fh)
49
+ return data if isinstance(data, dict) else {}
50
+ except (OSError, ValueError):
51
+ return {}
52
+
53
+
54
+ def _digest(path):
55
+ try:
56
+ with open(path, "rb") as fh:
57
+ return hashlib.sha256(fh.read()).hexdigest()[:12]
58
+ except OSError:
59
+ return None
60
+
61
+
62
+ def owner(command):
63
+ text = str(command)
64
+ return next((name for key, name in OWNERS if key in text), "other")
65
+
66
+
67
+ def yaml_list(text, key):
68
+ """The `- item` lines under `key:` in a block-style YAML file. omp's
69
+ config.yml is the only YAML read here, and only for two string lists."""
70
+ out, indent = [], None
71
+ for line in text.splitlines():
72
+ m = re.match(r"^(\s*)%s:\s*$" % re.escape(key), line)
73
+ if m:
74
+ indent = len(m.group(1))
75
+ continue
76
+ if indent is None:
77
+ continue
78
+ item = re.match(r"^(\s*)-\s+(.+?)\s*$", line)
79
+ if item and len(item.group(1)) >= indent:
80
+ out.append(item.group(2).strip("\"'"))
81
+ elif line.strip():
82
+ break
83
+ return out
84
+
85
+
86
+ def toml_tables(text, prefix):
87
+ """Names of `[prefix.<name>]` tables in a TOML file (Codex's MCP rows)."""
88
+ return sorted({m.group(1) for m in re.finditer(
89
+ r"^\[%s\.([A-Za-z0-9_-]+)\]" % re.escape(prefix), text, re.M)})
90
+
91
+
92
+ def hook_rows(data, source, who=None):
93
+ """{event: [(owner, source, matcher + command)]} from a Claude/Codex/Cursor
94
+ hooks object. `who` names the owner of every row (a plugin's own hooks
95
+ file). One command under two matchers answers two different tool sets, so
96
+ the matcher is part of what makes a row a duplicate."""
97
+ out = {}
98
+ hooks = data.get("hooks", {})
99
+ for event, groups in (hooks.items() if isinstance(hooks, dict) else []):
100
+ for group in groups if isinstance(groups, list) else []:
101
+ entries = (group.get("hooks") or [group]) if isinstance(group, dict) else []
102
+ matcher = str(group.get("matcher") or "") if isinstance(group, dict) else ""
103
+ for entry in entries:
104
+ if isinstance(entry, dict) and entry.get("command"):
105
+ cmd = str(entry["command"])
106
+ key = "%s %s" % (matcher, cmd) if matcher else cmd
107
+ out.setdefault(event, []).append((who or owner(cmd), source, key))
108
+ return out
109
+
110
+
111
+ def merge_hooks(*parts):
112
+ out = {}
113
+ for part in parts:
114
+ for event, rows in part.items():
115
+ out.setdefault(event, []).extend(rows)
116
+ return out
117
+
118
+
119
+ def claude_plugin():
120
+ """(root of the tezgah plugin copy Claude loads, every tezgah copy on disk)."""
121
+ plugins = os.path.join(tp.HOST_DIRS["claude"], "plugins")
122
+ rows = _json(os.path.join(plugins, "installed_plugins.json")).get("plugins") or {}
123
+ active = next((r[-1].get("installPath") for k, r in rows.items()
124
+ if k.split("@")[0] == "tezgah" and r), None)
125
+ cache = os.path.join(plugins, "cache")
126
+ copies = []
127
+ for market in sorted(os.listdir(cache)) if os.path.isdir(cache) else []:
128
+ base = os.path.join(cache, market, "tezgah")
129
+ if os.path.isdir(base):
130
+ copies += [os.path.join(base, v) for v in sorted(os.listdir(base))]
131
+ return active, copies
132
+
133
+
134
+ def host_stack(host, repo):
135
+ """{context, skills: (mode, [dir]), hooks, mcp, scan} for one host.
136
+
137
+ `mode` is `first` when the host keeps the first copy of a name, `all` when
138
+ it lists every copy (Codex says so; Cursor documents no winner). `scan` is
139
+ every dir the host enumerates, where a stray backup is one more entry."""
140
+ h = tp.HOME
141
+ agents_user = os.path.join(h, ".agents", "skills")
142
+ agents_repo = os.path.join(repo, ".agents", "skills")
143
+ if host == "claude":
144
+ c = tp.HOST_DIRS["claude"]
145
+ active, _ = claude_plugin()
146
+ plugin_hooks = hook_rows(_json(os.path.join(active, "hooks", "hooks.json")),
147
+ "plugin", "tezgah") if active else {}
148
+ mcp = sorted(_json(os.path.join(h, ".claude.json")).get("mcpServers") or {})
149
+ mcp += ["%s (project)" % n for n in sorted(
150
+ _json(os.path.join(repo, ".mcp.json")).get("mcpServers") or {})]
151
+ if active:
152
+ mcp += ["%s (plugin)" % n for n in sorted(
153
+ _json(os.path.join(active, ".mcp.json")).get("mcpServers") or {})]
154
+ return {
155
+ "context": [os.path.join(c, "CLAUDE.md"), os.path.join(repo, "CLAUDE.md"),
156
+ os.path.join(repo, ".claude", "CLAUDE.md"),
157
+ os.path.join(repo, "CLAUDE.local.md")],
158
+ "skills": ("first", [os.path.join(repo, ".claude", "skills"),
159
+ os.path.join(c, "skills")]),
160
+ "hooks": merge_hooks(
161
+ hook_rows(_json(os.path.join(c, "settings.json")), "settings.json"),
162
+ hook_rows(_json(os.path.join(repo, ".claude", "settings.json")), "project"),
163
+ plugin_hooks),
164
+ "mcp": mcp,
165
+ "scan": [os.path.join(c, "agents"), os.path.join(c, "skills"),
166
+ os.path.join(c, "commands")],
167
+ }
168
+ if host == "codex":
169
+ c = tp.HOST_DIRS["codex"]
170
+ user = os.path.join(c, "AGENTS.override.md")
171
+ return {
172
+ "context": [user if os.path.isfile(user) else os.path.join(c, "AGENTS.md"),
173
+ os.path.join(repo, "AGENTS.md")],
174
+ "skills": ("all", [agents_repo, agents_user, os.path.join(c, "skills")]),
175
+ "hooks": merge_hooks(
176
+ hook_rows(_json(os.path.join(c, "hooks.json")), "hooks.json"),
177
+ hook_rows(_json(os.path.join(repo, ".codex", "hooks.json")), "project")),
178
+ "mcp": toml_tables(_read(os.path.join(c, "config.toml")), "mcp_servers"),
179
+ "scan": [os.path.join(c, "skills"), os.path.join(c, "rules"),
180
+ os.path.join(c, "prompts")],
181
+ }
182
+ if host == "cursor":
183
+ c = tp.HOST_DIRS["cursor"]
184
+ rules = os.path.join(repo, ".cursor", "rules")
185
+ return {
186
+ "context": [os.path.join(repo, "AGENTS.md"), os.path.join(repo, ".cursorrules")]
187
+ + sorted(os.path.join(rules, n) for n in (
188
+ os.listdir(rules) if os.path.isdir(rules) else []) if n.endswith(".mdc")),
189
+ # Cursor's documented order; it names no winner for a shared name
190
+ "skills": ("all", [agents_repo, os.path.join(repo, ".cursor", "skills"),
191
+ agents_user, os.path.join(c, "skills"),
192
+ os.path.join(repo, ".claude", "skills"),
193
+ os.path.join(repo, ".codex", "skills"),
194
+ os.path.join(h, ".claude", "skills"),
195
+ os.path.join(h, ".codex", "skills")]),
196
+ "hooks": merge_hooks(
197
+ hook_rows(_json(os.path.join(c, "hooks.json")), "hooks.json"),
198
+ hook_rows(_json(os.path.join(repo, ".cursor", "hooks.json")), "project")),
199
+ "mcp": sorted(_json(os.path.join(c, "mcp.json")).get("mcpServers") or {}),
200
+ "scan": [os.path.join(c, "skills"), rules],
201
+ }
202
+ if host == "opencode":
203
+ c = tp.HOST_DIRS["opencode"]
204
+ conf = _json(os.path.join(c, "opencode.json"))
205
+ plugins = []
206
+ for d in ("plugins", "plugin"):
207
+ p = os.path.join(c, d)
208
+ plugins += [(n, d) for n in sorted(os.listdir(p) if os.path.isdir(p) else [])
209
+ if n.endswith((".js", ".ts"))]
210
+ return {
211
+ "context": [os.path.join(c, "AGENTS.md")]
212
+ + [p for p in conf.get("instructions") or [] if isinstance(p, str)]
213
+ + [os.path.join(repo, "AGENTS.md")],
214
+ # tezgah denies opencode's native skill tool; its router reads these, first wins
215
+ "skills": ("first", [os.path.join(c, "skills"), os.path.join(h, ".claude", "skills"),
216
+ agents_user]),
217
+ "hooks": {"plugin (every event)": [(owner(n), "%s/%s" % (d, n), n)
218
+ for n, d in plugins]},
219
+ "mcp": sorted(conf.get("mcp") or {}),
220
+ "scan": [os.path.join(c, "skills"), os.path.join(c, "plugins"),
221
+ os.path.join(c, "plugin")],
222
+ }
223
+ if host == "omp":
224
+ a = os.path.join(tp.HOST_DIRS["omp"], "agent")
225
+ conf = _read(os.path.join(a, "config.yml"))
226
+ enabled = set(yaml_list(conf, "enabledProviders"))
227
+ custom = yaml_list(conf, "customDirectories")
228
+ skills = list(custom) + [os.path.join(repo, ".omp", "skills"),
229
+ os.path.join(a, "skills"),
230
+ os.path.join(repo, ".claude", "skills")]
231
+ if "claude" in enabled or os.environ.get("CLAUDE_CONFIG_DIR"):
232
+ skills.append(os.path.join(h, ".claude", "skills"))
233
+ skills += [agents_repo, agents_user]
234
+ if "codex" in enabled:
235
+ skills.append(os.path.join(h, ".codex", "skills"))
236
+ ext_dir = os.path.join(a, "extensions")
237
+ found = sorted(os.path.join(ext_dir, n) for n in (
238
+ os.listdir(ext_dir) if os.path.isdir(ext_dir) else []) if n.endswith((".ts", ".js")))
239
+ configured = [os.path.expanduser(p) for p in yaml_list(conf, "extensions")]
240
+ order = found + [p for p in configured if p not in found]
241
+ hooks = {"extension (every event)": [(owner(p), p, p) for p in order]}
242
+ for kind in ("pre", "post"):
243
+ d = os.path.join(a, "hooks", kind)
244
+ listed = sorted(os.listdir(d)) if os.path.isdir(d) else []
245
+ if listed:
246
+ hooks["hooks/%s capability" % kind] = [
247
+ (owner(n), n + ("" if n.endswith((".ts", ".js")) else " (listed, not loaded)"), n)
248
+ for n in listed if not n.startswith(".")]
249
+ mcp = sorted(_json(os.path.join(a, "mcp.json")).get("mcpServers") or {})
250
+ return {
251
+ "context": [os.path.join(a, "SYSTEM.md"), os.path.join(a, "APPEND_SYSTEM.md"),
252
+ os.path.join(a, "RULES.md"), os.path.join(repo, ".omp", "AGENTS.md"),
253
+ os.path.join(repo, "AGENTS.md"), os.path.join(repo, "CLAUDE.md")]
254
+ + ([os.path.join(h, ".claude", "CLAUDE.md")] if "claude" in enabled else []),
255
+ "skills": ("first", skills),
256
+ "hooks": hooks,
257
+ "mcp": mcp,
258
+ "scan": [os.path.join(a, "agents"), os.path.join(a, "skills"),
259
+ os.path.join(a, "hooks", "pre"), os.path.join(a, "hooks", "post"),
260
+ os.path.join(a, "rules"), ext_dir],
261
+ }
262
+ if host == "dsh":
263
+ c = tp.HOST_DIRS["dsh"]
264
+ return {"context": [os.path.join(c, "cordis.patch.yml")],
265
+ "skills": ("first", [os.path.join(c, "skills")]),
266
+ "hooks": {}, "mcp": [], "scan": [os.path.join(c, "skills")]}
267
+ raise ValueError(host)
268
+
269
+
270
+ def skill_copies(dirs):
271
+ """[(name, dir, sha of SKILL.md, realpath)] in dir order."""
272
+ out = []
273
+ for d in dirs:
274
+ for name in sorted(os.listdir(d)) if os.path.isdir(d) else []:
275
+ path = os.path.join(d, name, "SKILL.md")
276
+ if os.path.isfile(path):
277
+ out.append((name, d, _digest(path), os.path.realpath(path)))
278
+ return out
279
+
280
+
281
+ def tezgah_skill_dirs():
282
+ """The dirs tezgah-setup links its skills into, plus the plugin copy's."""
283
+ dirs = [os.path.join(tp.HOST_DIRS[x], "skills") for x in ("codex", "cursor", "opencode", "dsh")]
284
+ dirs.append(os.path.join(tp.HOST_DIRS["omp"], "agent", "skills"))
285
+ active, _ = claude_plugin()
286
+ return dirs + ([os.path.join(active, "skills")] if active else [])
287
+
288
+
289
+ def conflicts(host, stack, repo):
290
+ """[(kind, detail)] for one host's stack."""
291
+ out = []
292
+ mode, dirs = stack["skills"]
293
+ by_name = {}
294
+ for name, d, sha, real in skill_copies(dirs):
295
+ by_name.setdefault(name, []).append((d, sha, real))
296
+ shipped = set(os.listdir(TEZGAH_SKILLS)) if os.path.isdir(TEZGAH_SKILLS) else set()
297
+ if host == "claude":
298
+ active, _ = claude_plugin()
299
+ for name, d, sha, real in skill_copies([os.path.join(active, "skills")] if active else []):
300
+ by_name.setdefault("tezgah:" + name, []).append((d, sha, real))
301
+ owned = tezgah_skill_dirs()
302
+ for name, copies in sorted(by_name.items()):
303
+ bare = name.split(":", 1)[-1]
304
+ if bare in shipped:
305
+ want = _digest(os.path.join(TEZGAH_SKILLS, bare, "SKILL.md"))
306
+ out += [("skill-stale", "%s in %s differs from %s" % (bare, d, TEZGAH_SKILLS))
307
+ for d, sha, _real in copies if d in owned and sha != want]
308
+ if len(copies) < 2:
309
+ continue
310
+ where = ", ".join(d for d, _s, _r in copies)
311
+ if len({sha for _d, sha, _r in copies}) > 1:
312
+ win = copies[0][0] if mode == "first" else "no documented winner"
313
+ out.append(("skill-differs", "%s: %s (wins: %s)" % (name, where, win)))
314
+ elif mode == "all":
315
+ # a first-wins host collapses identical copies; a list-all host shows each
316
+ out.append(("skill-dup", "%s: %s" % (name, where)))
317
+ for event, rows in sorted(stack["hooks"].items()):
318
+ mine = [cmd for who, _src, cmd in rows if who == "tezgah"]
319
+ twice = sorted({cmd for cmd in mine if mine.count(cmd) > 1})
320
+ if twice and "every event" not in event:
321
+ out.append(("hook-twice", "%s: %s" % (event, ", ".join(twice))))
322
+ names = [n.split(" (")[0] for n in stack["mcp"]]
323
+ out += [("mcp-twice", "%s: %s" % (n, ", ".join(m for m in stack["mcp"]
324
+ if m.split(" (")[0] == n)))
325
+ for n in sorted(set(names)) if names.count(n) > 1]
326
+ carrying = [p for p in stack["context"] if BLOCK in _read(p)]
327
+ if len(carrying) > 1:
328
+ out.append(("rule-twice", ", ".join(carrying)))
329
+ for d in stack["scan"]:
330
+ for root, dirnames, files in os.walk(d) if os.path.isdir(d) else []:
331
+ dirnames[:] = [] if root.count(os.sep) - d.count(os.sep) >= 1 else dirnames
332
+ out += [("backup-in-scan", os.path.join(root, n))
333
+ for n in sorted(files) if n.endswith(".tezgah-bak")]
334
+ if host == "claude":
335
+ active, copies = claude_plugin()
336
+ out += [("orphan-copy", p) for p in copies
337
+ if active and os.path.realpath(p) != os.path.realpath(active)]
338
+ return out
339
+
340
+
341
+ def report(repo, hosts=None):
342
+ """{host: stack + conflicts} for the installed hosts (or `hosts`)."""
343
+ repo = os.path.abspath(repo)
344
+ out = {}
345
+ for host in hosts or [h for h in tp.HOST_DIRS if tp.host_installed(h)]:
346
+ stack = host_stack(host, repo)
347
+ mode, dirs = stack["skills"]
348
+ copies = skill_copies(dirs)
349
+ out[host] = {
350
+ "context": [p for p in stack["context"] if os.path.isfile(p)],
351
+ "skill_mode": mode,
352
+ "skill_dirs": [(d, sum(1 for c in copies if c[1] == d))
353
+ for d in dirs if os.path.isdir(d)],
354
+ "skills": len({c[0] for c in copies}),
355
+ "hooks": stack["hooks"],
356
+ "mcp": stack["mcp"],
357
+ "conflicts": conflicts(host, stack, repo),
358
+ }
359
+ return out
360
+
361
+
362
+ def render(data):
363
+ lines = []
364
+ for host, s in data.items():
365
+ lines.append("== %s" % host)
366
+ lines.append(" context (load order):")
367
+ lines += [" %s" % p for p in s["context"]] or [" (none)"]
368
+ lines.append(" skills: %d names, %s copy wins" % (
369
+ s["skills"], "first" if s["skill_mode"] == "first" else "no single"))
370
+ lines += [" %4d %s" % (n, d) for d, n in s["skill_dirs"]]
371
+ lines.append(" hooks:")
372
+ for event, rows in sorted(s["hooks"].items()):
373
+ lines.append(" %-28s %s" % (event, " -> ".join(
374
+ "%s[%s]" % (who, src) for who, src, _cmd in rows)))
375
+ lines.append(" mcp: %s" % (", ".join(s["mcp"]) or "(none)"))
376
+ counts = {}
377
+ for kind, _ in s["conflicts"]:
378
+ counts[kind] = counts.get(kind, 0) + 1
379
+ lines.append(" conflicts: %s" % (", ".join(
380
+ "%s=%d" % kv for kv in sorted(counts.items())) or "none"))
381
+ lines += [" %s %s" % kv for kv in s["conflicts"]]
382
+ return "\n".join(lines)
@@ -52,7 +52,7 @@ ROW_ATOMS = (
52
52
  "Dc", # a component write (`_design_evidence`)
53
53
  "Dk", # a `tezgah-design check` that passed
54
54
  "X", # a read of an external system's own state (`_external_read_row`)
55
- "W", # a work row (`WORK_KINDS`)
55
+ "W", # a work row (`_work_row`: WORK_KINDS less a prose edit)
56
56
  )
57
57
  SCOPE_ATOMS = ("T",) # the turn marker
58
58
 
@@ -213,7 +213,7 @@ class Trace:
213
213
  and kind in ("run", "verify", "verify_ok", "verify_fail")
214
214
  and bool(ti.EXTERNAL_READ.search(self.masked(i))))
215
215
  if name == "W":
216
- return kind in ti.WORK_KINDS
216
+ return ti._work_row(row)
217
217
  if name == "T":
218
218
  return kind == ti.TURN_KIND
219
219
  raise KeyError(name)
@@ -29,7 +29,7 @@ try:
29
29
  from tezgah_context import ( # noqa: E402
30
30
  TOOL_USE_MEASURES, command_text, context_for, health_lines, record,
31
31
  shell_kind)
32
- from tezgah_gate import decision # noqa: E402
32
+ from tezgah_gate import decision, graph_advice # noqa: E402
33
33
  from tezgah_integrity import ( # noqa: E402
34
34
  SUBAGENT_CHANNEL, bind_session, changed_files_notice, note_tool, ran_nothing,
35
35
  report_bytes, stop_reason, untrusted_source)
@@ -195,6 +195,12 @@ def main():
195
195
  if untrusted_source(tool, inp) == SUBAGENT_CHANNEL
196
196
  else result_size(result)),
197
197
  empty_run=ran_nothing(result))
198
+ # The gate's graph line for a code-symbol search rides the same
199
+ # non-blocking channel (tezgah_gate.graph_advice): a Codex PreToolUse
200
+ # carries a deny and nothing else.
201
+ advice = (safe(session_id, graph_advice, tool, inp, cwd, "codex")
202
+ if root_for(cwd) else None)
203
+ notice = "\n".join(p for p in (notice, advice) if p)
198
204
  if notice:
199
205
  # Codex's PostToolUse output carries `additionalContext` with the
200
206
  # result - the field is part of its own hook output schema
@@ -48,7 +48,7 @@ from tezgah_guard import attest_session, import_failed, safe # noqa: E402
48
48
  try:
49
49
  from tezgah_context import ( # noqa: E402
50
50
  command_text, context_for, record, shell_kind, slug, under)
51
- from tezgah_gate import decision, explored # noqa: E402
51
+ from tezgah_gate import decision, explored, graph_advice # noqa: E402
52
52
  from tezgah_integrity import ( # noqa: E402
53
53
  SUBAGENT_CHANNEL, bind_session, note, note_tool, ran_nothing, report_bytes,
54
54
  stop_reason)
@@ -318,9 +318,12 @@ def dispatch(payload):
318
318
  if (kind in ("graph", "consult") and under(cwd) and not quiet
319
319
  and first_time(session_id, "graph")):
320
320
  reinforce = REINFORCE
321
- # both are one line for the model, on the result they belong to; a turn
322
- # that earns both reads them together rather than one replacing the other
323
- text = "\n".join(t for t in (notice, reinforce) if t)
321
+ # the gate's graph line for a code-symbol search (graph_advice) rides
322
+ # the same channel; all three are one line each for the model, on the
323
+ # result they belong to, read together rather than one replacing another
324
+ advice = (graph_advice(gate_name(payload.get("tool_name", "")), inp, cwd,
325
+ "cursor") if under(cwd) and not quiet else None)
326
+ text = "\n".join(t for t in (notice, reinforce, advice) if t)
324
327
  out = {"additional_context": text} if text else {}
325
328
  elif event == "postToolUseFailure":
326
329
  if kind:
package/hosts/omp/hook.py CHANGED
@@ -19,7 +19,8 @@ the same code every other host runs.
19
19
  {"event": "user_prompt", "cwd": ..., "prompt": ...}
20
20
  -> {"context": <reminder + the rules this prompt arms>}
21
21
  {"event": "pre_tool_use", "cwd": ..., "tool": ..., "input": {...}}
22
- -> {"deny": reason}
22
+ -> {"deny": reason} | {"advice": line} (the graph line the bridge hands
23
+ the model with this call's result; never a block)
23
24
  {"event": "post_tool_use", "cwd": ..., "tool": ..., "input": {...},
24
25
  "failed": bool, "idx": glyph}
25
26
  -> {"status": "pony✓ ...", "idx": glyph, "label": one line}
@@ -57,7 +58,7 @@ try:
57
58
  from tezgah_context import ( # noqa: E402
58
59
  color_default, command_text, context_for, health_segments, record,
59
60
  render_tiers, shell_kind, skill_read_kind)
60
- from tezgah_gate import decision # noqa: E402
61
+ from tezgah_gate import decision, graph_advice # noqa: E402
61
62
  from tezgah_integrity import ( # noqa: E402
62
63
  SUBAGENT_CHANNEL, bind_session, note, note_tool, stop_reason, untrusted_label,
63
64
  untrusted_source)
@@ -142,9 +143,12 @@ def handle(payload):
142
143
  # answered at every turn boundary
143
144
  return answered(*status_line(cwd, session_id))
144
145
  if event == "pre_tool_use":
145
- reason = decision(payload.get("tool", ""), payload.get("input") or {},
146
- cwd, session_id)
147
- return {"deny": reason} if reason else {}
146
+ tool, inp = payload.get("tool", ""), payload.get("input") or {}
147
+ reason = decision(tool, inp, cwd, session_id)
148
+ if reason:
149
+ return {"deny": reason}
150
+ advice = graph_advice(tool, inp, cwd, "omp")
151
+ return {"advice": advice} if advice else {}
148
152
  if not root_for(cwd):
149
153
  return {}
150
154
  if event == "session_start":
@@ -179,7 +183,9 @@ def handle(payload):
179
183
  with_core=False)
180
184
  return {"context": context} if context else {}
181
185
  if event == "user_prompt":
182
- context = context_for("user_prompt", cwd, payload)
186
+ # host="omp": the skill hint drops the absolute path, since omp's
187
+ # read tool resolves the skill:// address on its own
188
+ context = context_for("user_prompt", cwd, dict(payload, host="omp"))
183
189
  return {"context": context} if context else {}
184
190
  if event == "post_tool_use":
185
191
  tool = payload.get("tool", "")
@@ -263,11 +263,17 @@ function skillRead(tool: string, args: any): boolean {
263
263
 
264
264
  // The gate is only consulted for the tools the shared rule knows: the refusal
265
265
  // of the grep-only explorer (task/agent), the anti-shortcut and attribution
266
- // checks (bash), and the test-skip check (the edit/write names). An MCP tool is
266
+ // checks (bash), the test-skip check (the edit/write names), and the code-graph
267
+ // nudge on omp's search tools (grep, find, search, ast_grep). An MCP tool is
267
268
  // consulted when its name carries an effect verb (MCP_EFFECT, see `tool_call`).
268
- const GATED = ["grep", "bash", "shell", "command", "task", "agent", "edit",
269
- "write", "multiedit", "notebookedit", "apply_patch", "edit_file", "write_file",
270
- "search_replace", "powershell", "pwsh"];
269
+ const GATED = ["grep", "find", "search", "ast_grep", "bash", "shell", "command",
270
+ "task", "agent", "edit", "write", "multiedit", "notebookedit", "apply_patch",
271
+ "edit_file", "write_file", "search_replace", "powershell", "pwsh"];
272
+
273
+ // The gate's non-blocking graph line (tezgah_gate.graph_advice), held from the
274
+ // call to its result: `tool_call` can only block, `tool_result` can hand the
275
+ // model `additionalContext`, and carrying it across costs no second spawn.
276
+ const ADVICE = new Map<string, string>();
271
277
 
272
278
  // The effect verbs of tezgah_integrity.MCP_VERBS, as one pattern: a read-only
273
279
  // MCP call (a code-graph query) reaches no content rule, so it pays no spawn.
@@ -647,15 +653,22 @@ export default function tezgahOmp(pi: any): void {
647
653
  return undefined; // a hook that cannot answer cannot refuse either
648
654
  }
649
655
  if (res.deny) return { block: true, reason: res.deny };
656
+ if (res.advice && event?.toolCallId != null) {
657
+ ADVICE.set(String(event.toolCallId), String(res.advice));
658
+ }
650
659
  return undefined;
651
660
  });
652
661
 
653
662
  pi.on("tool_result", async (event: any, ctx: any) => {
654
663
  const tool = String(event?.toolName ?? "");
655
664
  const args = event?.input ?? {};
665
+ const id = event?.toolCallId != null ? String(event.toolCallId) : "";
666
+ const advice = ADVICE.get(id);
667
+ ADVICE.delete(id);
668
+ const graph = advice ? { additionalContext: advice } : undefined;
656
669
  if (!WATCHED.includes(tool.toLowerCase()) && !tool.startsWith("mcp__")
657
670
  && !skillRead(tool, args)) {
658
- return;
671
+ return graph;
659
672
  }
660
673
  // Tri-state: an unread outcome must never be written as a passed check,
661
674
  // because the Stop rule below reads this ledger as evidence.
@@ -680,9 +693,9 @@ export default function tezgahOmp(pi: any): void {
680
693
  // place; every other result is left exactly as the host produced it.
681
694
  if (res.label) {
682
695
  const content = labelled(event?.content, res.label);
683
- if (content) return { content };
696
+ if (content) return { content, ...graph };
684
697
  }
685
- return undefined;
698
+ return graph;
686
699
  });
687
700
 
688
701
  pi.on("session_stop", async (event: any, ctx: any) => {
@@ -105,7 +105,8 @@ const SETUP_BIN = join(CONFIG, "bin", "tezgah-setup")
105
105
  const CONTEXT_BIN = join(CONFIG, "bin", "tezgah-context")
106
106
  const CAPTURE_BIN = join(CONFIG, "bin", "tezgah-capture")
107
107
  const GATE_BIN = join(CONFIG, "bin", "tezgah-gate")
108
- const IDENT = /^[A-Za-z_][A-Za-z0-9_]{2,}$/
108
+ const SEARCH_SYMBOL =
109
+ /^(?:\\b)?(?:(def|class|function|func|fn|interface|struct|type)\s+)?([A-Za-z_][A-Za-z0-9_]{2,})(\\?\()?(?:\\b)?$/
109
110
  // A hung core must not hold a tool call open. Every awaited core CLI runs
110
111
  // through `collect`, which gives it a deadline and answers a timeout with the
111
112
  // same fail-open value a missing binary or a non-zero exit gets: a core that
@@ -2111,10 +2112,12 @@ function ledgerStem(sessionID) {
2111
2112
  }
2112
2113
 
2113
2114
  // The nearest enclosing project that holds a codegraph index. The answer is the
2114
- // indexed directory itself, which is what the nudge names.
2115
+ // indexed directory itself, which is what the nudge names; a `.no-graph` met
2116
+ // first is the repo's own switch (hooks/tezgah_gate.index_slug), so no index.
2115
2117
  function indexSlug(dir) {
2116
2118
  let d = dir
2117
2119
  for (;;) {
2120
+ if (existsSync(join(d, ".no-graph"))) return null
2118
2121
  if (existsSync(join(d, GRAPH_DB))) return d
2119
2122
  const parent = d.replace(/\/[^/]+\/?$/, "")
2120
2123
  if (!parent || parent === d) return null
@@ -2122,17 +2125,23 @@ function indexSlug(dir) {
2122
2125
  }
2123
2126
  }
2124
2127
 
2128
+ // hooks/tezgah_gate.symbol_in: a code symbol (inner underscore, a lower-to-upper
2129
+ // step, or any identifier in a `def x` / `x(` pattern); a plain word such as
2130
+ // `TODO` is literal text and is never nudged.
2131
+ function symbolIn(text) {
2132
+ const m = String(text || "").trim().replace(/^['"]+|['"]+$/g, "").match(SEARCH_SYMBOL)
2133
+ if (!m) return null
2134
+ const tok = m[2]
2135
+ const shaped = tok.replace(/^_+|_+$/g, "").includes("_") || /[a-z][A-Z]/.test(tok)
2136
+ return m[1] || m[3] || shaped ? tok : null
2137
+ }
2138
+
2125
2139
  function identifierFrom(tool, args) {
2126
- if (tool === "grep") {
2127
- const p = String(args.pattern || "")
2128
- return IDENT.test(p) ? p : null
2129
- }
2140
+ if (tool === "grep") return symbolIn(args.pattern)
2130
2141
  if (tool === "bash" || tool === "shell") {
2131
2142
  const cmd = String(args.command || "")
2132
2143
  const m = cmd.match(/(?:^|[|;&(]\s*|\s)(?:grep|rg)\s+((?:-\S+\s+)*)(\S+)/)
2133
- if (!m) return null
2134
- const tok = m[2].replace(/^['"]|['"]$/g, "")
2135
- return IDENT.test(tok) ? tok : null
2144
+ return m ? symbolIn(m[2]) : null
2136
2145
  }
2137
2146
  return null
2138
2147
  }