@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.
- package/CHANGELOG.md +71 -1
- package/MANIFEST +8 -0
- package/bin/tezgah-agents +10 -1
- package/bin/tezgah-docs +1 -1
- package/bin/tezgah-doctor +11 -0
- package/bin/tezgah-research +31 -14
- package/bin/tezgah-setup +154 -29
- package/bin/tezgah-skill +59 -0
- package/bin/tezgah-skill.py +59 -0
- package/bin/tezgah-status +62 -1
- package/bin/tezgah-triage +5 -3
- package/docs/README.md +1 -0
- package/docs/architecture.md +5 -2
- package/docs/citations-baseline.json +1 -1
- package/docs/clarity-baseline.json +9 -9
- package/docs/contract.md +68 -26
- package/docs/environment.md +6 -3
- package/docs/evidence.md +7 -0
- package/docs/gate.md +13 -7
- package/docs/glossary.md +1 -1
- package/docs/hosts.md +48 -1
- package/docs/index.json +29 -1
- package/docs/judge.md +61 -55
- package/docs/models.md +14 -8
- package/docs/operations.md +10 -5
- package/docs/orca.md +126 -0
- package/docs/research.md +38 -3
- package/docs/skills.md +74 -0
- package/docs/status-line.md +3 -1
- package/docs/testing.md +33 -21
- package/docs/vision.md +12 -8
- package/hooks/projects-pretooluse.py +17 -5
- package/hooks/tezgah_agents.py +135 -13
- package/hooks/tezgah_components.py +3 -0
- package/hooks/tezgah_context.py +65 -22
- package/hooks/tezgah_gate.py +133 -17
- package/hooks/tezgah_integrity.py +60 -13
- package/hooks/tezgah_judge.py +239 -56
- package/hooks/tezgah_models.py +41 -5
- package/hooks/tezgah_orca.py +144 -0
- package/hooks/tezgah_paths.py +40 -6
- package/hooks/tezgah_policy.py +109 -35
- package/hooks/tezgah_research.py +104 -8
- package/hooks/tezgah_skill_pick.py +459 -8
- package/hooks/tezgah_stack.py +382 -0
- package/hooks/tezgah_stopspec.py +2 -2
- package/hosts/codex/hook.py +7 -1
- package/hosts/cursor/hook.py +7 -4
- package/hosts/omp/hook.py +12 -6
- package/hosts/omp/tezgah-hook.ts.in +20 -7
- package/hosts/opencode/plugins/tezgah.js +18 -9
- package/output-styles/tezgah.md +34 -19
- package/package.json +1 -1
- package/skills/i-have-adhd/SKILL.md +2 -2
- package/skills/research/SKILL.md +60 -17
- 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)
|
package/hooks/tezgah_stopspec.py
CHANGED
|
@@ -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
|
|
216
|
+
return ti._work_row(row)
|
|
217
217
|
if name == "T":
|
|
218
218
|
return kind == ti.TURN_KIND
|
|
219
219
|
raise KeyError(name)
|
package/hosts/codex/hook.py
CHANGED
|
@@ -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
|
package/hosts/cursor/hook.py
CHANGED
|
@@ -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
|
-
#
|
|
322
|
-
#
|
|
323
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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),
|
|
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", "
|
|
269
|
-
"
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|