@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/MANIFEST +10 -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-taste +504 -5
  12. package/bin/tezgah-triage +5 -3
  13. package/docs/README.md +1 -0
  14. package/docs/architecture.md +5 -2
  15. package/docs/citations-baseline.json +1 -1
  16. package/docs/clarity-baseline.json +9 -9
  17. package/docs/contract.md +68 -26
  18. package/docs/environment.md +6 -3
  19. package/docs/evidence.md +7 -0
  20. package/docs/gate.md +13 -7
  21. package/docs/glossary.md +1 -1
  22. package/docs/hosts.md +48 -1
  23. package/docs/index.json +34 -3
  24. package/docs/judge.md +75 -60
  25. package/docs/models.md +14 -8
  26. package/docs/operations.md +80 -25
  27. package/docs/orca.md +126 -0
  28. package/docs/research.md +38 -3
  29. package/docs/skills.md +74 -0
  30. package/docs/status-line.md +3 -1
  31. package/docs/testing.md +33 -21
  32. package/docs/vision.md +12 -8
  33. package/hooks/projects-posttooluse.py +7 -0
  34. package/hooks/projects-pretooluse.py +17 -5
  35. package/hooks/tezgah_agents.py +135 -13
  36. package/hooks/tezgah_components.py +3 -0
  37. package/hooks/tezgah_context.py +84 -22
  38. package/hooks/tezgah_gate.py +133 -17
  39. package/hooks/tezgah_integrity.py +60 -13
  40. package/hooks/tezgah_judge.py +288 -66
  41. package/hooks/tezgah_models.py +41 -5
  42. package/hooks/tezgah_orca.py +144 -0
  43. package/hooks/tezgah_paths.py +40 -6
  44. package/hooks/tezgah_policy.py +109 -35
  45. package/hooks/tezgah_research.py +104 -8
  46. package/hooks/tezgah_skill_pick.py +459 -8
  47. package/hooks/tezgah_stack.py +382 -0
  48. package/hooks/tezgah_stopspec.py +2 -2
  49. package/hooks/tezgah_taste.py +35 -9
  50. package/hooks/tezgah_taste_ledger.py +628 -0
  51. package/hosts/codex/hook.py +12 -1
  52. package/hosts/cursor/hook.py +12 -4
  53. package/hosts/omp/hook.py +18 -6
  54. package/hosts/omp/tezgah-hook.ts.in +20 -7
  55. package/hosts/opencode/plugins/tezgah.js +28 -15
  56. package/output-styles/tezgah.md +34 -19
  57. package/package.json +1 -1
  58. package/skills/i-have-adhd/SKILL.md +2 -2
  59. package/skills/research/SKILL.md +60 -17
  60. 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)
@@ -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, makes no network call and
13
- appends at most one row. They run on the prompt and PostToolUse hot paths, so
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
- Nothing here is learned or fed back into a prompt; this is capture only.
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 that cannot call it
186
- in process (opencode's plugin is JavaScript). The one argument is
187
- {"session_id", "id", "input", "cwd", "host"}. Silent, exit 0 always."""
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("cwd") or os.getcwd(),
193
- payload.get("host"))
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