@r1z4x/tezgah 1.0.0 → 1.2.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 +92 -1
- package/MANIFEST +10 -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-taste +504 -5
- 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 +34 -3
- package/docs/judge.md +75 -60
- package/docs/models.md +14 -8
- package/docs/operations.md +80 -25
- 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-posttooluse.py +7 -0
- 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 +84 -22
- package/hooks/tezgah_gate.py +133 -17
- package/hooks/tezgah_integrity.py +60 -13
- package/hooks/tezgah_judge.py +288 -66
- 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/hooks/tezgah_taste.py +35 -9
- package/hooks/tezgah_taste_ledger.py +628 -0
- package/hosts/codex/hook.py +12 -1
- package/hosts/cursor/hook.py +12 -4
- package/hosts/omp/hook.py +18 -6
- package/hosts/omp/tezgah-hook.ts.in +20 -7
- package/hosts/opencode/plugins/tezgah.js +28 -15
- 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/hooks/tezgah_taste.py
CHANGED
|
@@ -9,11 +9,12 @@ workspace that came with the clone is data, and nothing of the user's is
|
|
|
9
9
|
written into it). The store is `<git root>/.tezgah/taste/signals.jsonl`, one
|
|
10
10
|
JSON object per line, owner-only.
|
|
11
11
|
|
|
12
|
-
Every public function is total: it never raises
|
|
13
|
-
|
|
14
|
-
the switch is read before anything else is.
|
|
12
|
+
Every public function is total: it never raises and makes no network call.
|
|
13
|
+
The capture functions append at most one row each. They run on the prompt and
|
|
14
|
+
PostToolUse hot paths, so the switch is read before anything else is.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
Learning lives in `bin/tezgah-taste learn` and `tezgah_taste_ledger`; the one
|
|
17
|
+
thing fed back from here is `write_note`, the learnings in scope for a write.
|
|
17
18
|
"""
|
|
18
19
|
import datetime
|
|
19
20
|
import json
|
|
@@ -181,16 +182,41 @@ def note_write(session_id, row_id, inp, cwd, host=None):
|
|
|
181
182
|
pass
|
|
182
183
|
|
|
183
184
|
|
|
185
|
+
def write_note(session_id, inp, cwd, tool=None):
|
|
186
|
+
"""The in-scope taste learnings this write has not yet shown in the session
|
|
187
|
+
(`tezgah_taste_ledger.write_note`), or "". Every host's post-tool
|
|
188
|
+
channel carries it beside its other notices; off, it costs the marker stat.
|
|
189
|
+
`tool` is the call's tool name; a call that is not a write gets nothing."""
|
|
190
|
+
try:
|
|
191
|
+
if not armed(ARM) or not isinstance(inp, dict) or inp.get("command") == "view" \
|
|
192
|
+
or (tool is not None and str(tool).lower() not in ti.WRITE_TOOLS):
|
|
193
|
+
return ""
|
|
194
|
+
from tezgah_gate import write_paths
|
|
195
|
+
paths = write_paths(inp)
|
|
196
|
+
root = enabled(cwd)
|
|
197
|
+
rel = _rel(root, paths[0], cwd) if root and paths else None
|
|
198
|
+
if rel is None:
|
|
199
|
+
return ""
|
|
200
|
+
import tezgah_taste_ledger
|
|
201
|
+
return tezgah_taste_ledger.write_note(root, session_id, rel)
|
|
202
|
+
except Exception:
|
|
203
|
+
return ""
|
|
204
|
+
|
|
205
|
+
|
|
184
206
|
def main(argv):
|
|
185
|
-
"""`tezgah_taste.py '<json>'`: `note_write` for a host
|
|
186
|
-
in process (opencode's plugin is JavaScript). The one
|
|
187
|
-
{"session_id", "id", "input", "cwd", "host"}.
|
|
207
|
+
"""`tezgah_taste.py '<json>'`: `note_write`, then `write_note`, for a host
|
|
208
|
+
that cannot call them in process (opencode's plugin is JavaScript). The one
|
|
209
|
+
argument is {"session_id", "id", "input", "cwd", "host"}. Prints the note
|
|
210
|
+
(nothing when there is none); exit 0 always."""
|
|
188
211
|
try:
|
|
189
212
|
payload = json.loads(argv[0]) if argv else {}
|
|
190
213
|
if isinstance(payload, dict):
|
|
214
|
+
cwd = payload.get("cwd") or os.getcwd()
|
|
191
215
|
note_write(payload.get("session_id"), payload.get("id"),
|
|
192
|
-
payload.get("input"), payload.get("
|
|
193
|
-
|
|
216
|
+
payload.get("input"), cwd, payload.get("host"))
|
|
217
|
+
note = write_note(payload.get("session_id"), payload.get("input"), cwd)
|
|
218
|
+
if note:
|
|
219
|
+
print(note)
|
|
194
220
|
except Exception:
|
|
195
221
|
pass
|
|
196
222
|
return 0
|