@r1z4x/tezgah 1.11.0 → 1.13.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 +333 -1
  2. package/MANIFEST +22 -0
  3. package/README.md +4 -3
  4. package/bin/tezgah-docs +1 -1
  5. package/bin/tezgah-research +39 -4
  6. package/bin/tezgah-setup +1229 -22
  7. package/bin/tezgah-task +55 -17
  8. package/docs/citations-baseline.json +1 -1
  9. package/docs/clarity-baseline.json +9 -3
  10. package/docs/contract.md +6 -3
  11. package/docs/environment.md +3 -2
  12. package/docs/evidence.md +81 -8
  13. package/docs/gate.md +6 -2
  14. package/docs/glossary.md +1 -1
  15. package/docs/hosts.md +297 -15
  16. package/docs/layers.md +1 -1
  17. package/docs/models.md +6 -0
  18. package/docs/operations.md +16 -5
  19. package/docs/research.md +36 -10
  20. package/docs/status-line.md +6 -0
  21. package/docs/testing.md +3 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/lang/en.json +11 -0
  24. package/hooks/lang/tr.json +11 -0
  25. package/hooks/projects-stop.py +2 -1
  26. package/hooks/tezgah_agents.py +27 -3
  27. package/hooks/tezgah_attest.py +79 -10
  28. package/hooks/tezgah_context.py +21 -8
  29. package/hooks/tezgah_gate.py +79 -23
  30. package/hooks/tezgah_integrity.py +212 -38
  31. package/hooks/tezgah_paths.py +43 -2
  32. package/hooks/tezgah_policy.py +35 -13
  33. package/hooks/tezgah_replay.py +8 -2
  34. package/hooks/tezgah_research.py +167 -24
  35. package/hooks/tezgah_stack.py +236 -1
  36. package/hooks/tezgah_task.py +2 -1
  37. package/hosts/amp/hook.py +217 -0
  38. package/hosts/amp/tezgah-plugin.ts.in +386 -0
  39. package/hosts/antigravity/hook.py +255 -0
  40. package/hosts/codex/hook.py +2 -1
  41. package/hosts/commandcode/hook.py +239 -0
  42. package/hosts/copilot/hook.py +284 -0
  43. package/hosts/cursor/hook.py +2 -1
  44. package/hosts/droid/hook.py +259 -0
  45. package/hosts/dsh/hooks.json +1 -1
  46. package/hosts/gemini/hook.py +213 -0
  47. package/hosts/goose/hook.py +145 -0
  48. package/hosts/omp/hook.py +4 -1
  49. package/hosts/omp/tezgah-hook.ts.in +30 -7
  50. package/hosts/opencode/plugins/tezgah.js +2 -2
  51. package/hosts/qwen/hook.py +206 -0
  52. package/output-styles/tezgah.md +10 -5
  53. package/package.json +1 -1
  54. package/skills/harness/SKILL.md +2 -0
  55. package/skills/i-have-adhd/SKILL.md +10 -1
  56. package/skills/plan-add/SKILL.md +5 -1
  57. package/skills/research/SKILL.md +22 -9
  58. package/skills/tezgah-contract/SKILL.md +19 -6
  59. package/statusline.py +20 -9
  60. package/workflows/graph-review.js +14 -2
@@ -83,6 +83,26 @@ def yaml_list(text, key):
83
83
  return out
84
84
 
85
85
 
86
+ def yaml_keys(text, key):
87
+ """The direct child keys of `key:` in a block-style YAML file (Goose's
88
+ `extensions:` map in config.yaml)."""
89
+ out, indent = [], None
90
+ for line in text.splitlines():
91
+ if indent is None:
92
+ if re.match(r"^%s:\s*$" % re.escape(key), line):
93
+ indent = -1
94
+ continue
95
+ if not line.strip() or line.lstrip().startswith("#"):
96
+ continue
97
+ m = re.match(r"^(\s+)([\w.-]+):", line)
98
+ if not m:
99
+ break
100
+ indent = len(m.group(1)) if indent < 0 else indent
101
+ if len(m.group(1)) == indent:
102
+ out.append(m.group(2))
103
+ return out
104
+
105
+
86
106
  def toml_tables(text, prefix):
87
107
  """Names of `[prefix.<name>]` tables in a TOML file (Codex's MCP rows)."""
88
108
  return sorted({m.group(1) for m in re.finditer(
@@ -264,6 +284,217 @@ def host_stack(host, repo):
264
284
  return {"context": [os.path.join(c, "cordis.patch.yml")],
265
285
  "skills": ("first", [os.path.join(c, "skills")]),
266
286
  "hooks": {}, "mcp": [], "scan": [os.path.join(c, "skills")]}
287
+ if host == "qwen":
288
+ c = tp.HOST_DIRS["qwen"]
289
+ user = _json(os.path.join(c, "settings.json"))
290
+ project = _json(os.path.join(repo, ".qwen", "settings.json"))
291
+ return {
292
+ # Qwen 0.21.13 docs (features/memory.md): all of these load
293
+ "context": [os.path.join(c, "QWEN.md"), os.path.join(repo, "QWEN.md"),
294
+ os.path.join(repo, "AGENTS.md"),
295
+ os.path.join(repo, ".qwen", "QWEN.local.md")],
296
+ # features/skills.md lists personal and project dirs and names no winner
297
+ "skills": ("all", [os.path.join(c, "skills"),
298
+ os.path.join(repo, ".qwen", "skills")]),
299
+ "hooks": merge_hooks(hook_rows(user, "settings.json"),
300
+ hook_rows(project, "project")),
301
+ "mcp": sorted(user.get("mcpServers") or {})
302
+ + ["%s (project)" % n for n in sorted(project.get("mcpServers") or {})],
303
+ "scan": [os.path.join(c, "skills")],
304
+ }
305
+ if host == "droid":
306
+ c, h = tp.HOST_DIRS["droid"], tp.HOME
307
+
308
+ def droid_hooks(d, source):
309
+ # hooks.json is keyed by event at the top; only when it is absent does
310
+ # Droid read the `hooks` key of settings.json (harness/hooks)
311
+ path = os.path.join(d, "hooks.json")
312
+ if os.path.exists(path):
313
+ return hook_rows({"hooks": _json(path)}, source)
314
+ return hook_rows(_json(os.path.join(d, "settings.json")), source)
315
+ return {
316
+ # harness/agents-md: cwd up to the git root and the personal dirs
317
+ "context": [os.path.join(c, "AGENTS.md"), os.path.join(h, ".agents", "AGENTS.md"),
318
+ os.path.join(repo, "AGENTS.md"),
319
+ os.path.join(repo, ".factory", "AGENTS.md"),
320
+ os.path.join(repo, "CLAUDE.md")],
321
+ # harness/skills lists the scopes and names no winner
322
+ "skills": ("all", [os.path.join(c, "skills"),
323
+ os.path.join(repo, ".factory", "skills"),
324
+ os.path.join(h, ".agents", "skills"),
325
+ os.path.join(repo, ".agents", "skills")]),
326
+ "hooks": merge_hooks(droid_hooks(c, "hooks.json"),
327
+ droid_hooks(os.path.join(repo, ".factory"), "project")),
328
+ "mcp": sorted(_json(os.path.join(c, "mcp.json")).get("mcpServers") or {}),
329
+ "scan": [os.path.join(c, "skills")],
330
+ }
331
+ if host == "commandcode":
332
+ c, h = tp.HOST_DIRS["commandcode"], tp.HOME
333
+ user = _json(os.path.join(c, "settings.json"))
334
+ project = _json(os.path.join(repo, ".commandcode", "settings.json"))
335
+ return {
336
+ # docs/memory: user, project, then subdirectory tiers; all load
337
+ "context": [os.path.join(c, "AGENTS.md"), os.path.join(repo, "AGENTS.md"),
338
+ os.path.join(repo, ".commandcode", "AGENTS.md")],
339
+ # docs/skills "Selection priority": project before user, and within a
340
+ # level .commandcode/ before .agents/
341
+ "skills": ("first", [os.path.join(repo, ".commandcode", "skills"),
342
+ os.path.join(repo, ".agents", "skills"),
343
+ os.path.join(c, "skills"),
344
+ os.path.join(h, ".agents", "skills")]),
345
+ "hooks": merge_hooks(hook_rows(user, "settings.json"),
346
+ hook_rows(project, "project")),
347
+ "mcp": sorted(_json(os.path.join(c, "mcp.json")).get("mcpServers") or {})
348
+ + ["%s (project)" % n for n in
349
+ sorted(_json(os.path.join(repo, ".mcp.json")).get("mcpServers") or {})],
350
+ "scan": [os.path.join(c, "skills")],
351
+ }
352
+ if host == "copilot":
353
+ c, h = tp.HOST_DIRS["copilot"], tp.HOME
354
+
355
+ def hook_files(d, source):
356
+ # every *.json in a hooks dir is one `{"version": 1, "hooks": ...}` file
357
+ names = sorted(n for n in os.listdir(d) if n.endswith(".json")) \
358
+ if os.path.isdir(d) else []
359
+ return merge_hooks(*(hook_rows(_json(os.path.join(d, n)), "%s %s" % (source, n))
360
+ for n in names))
361
+ return {
362
+ # cli-command-reference "Custom instructions locations": all merge
363
+ "context": [os.path.join(c, "copilot-instructions.md"),
364
+ os.path.join(repo, ".github", "copilot-instructions.md"),
365
+ os.path.join(repo, "AGENTS.md"), os.path.join(repo, "CLAUDE.md"),
366
+ os.path.join(repo, "GEMINI.md")],
367
+ # cli-command-reference "Skill locations": first found wins
368
+ "skills": ("first", [os.path.join(repo, ".github", "skills"),
369
+ os.path.join(repo, ".agents", "skills"),
370
+ os.path.join(repo, ".claude", "skills"),
371
+ os.path.join(c, "skills"),
372
+ os.path.join(h, ".agents", "skills")]),
373
+ # hooks-reference "Hooks locations": every source runs
374
+ "hooks": merge_hooks(hook_files(os.path.join(repo, ".github", "hooks"), "project"),
375
+ hook_files(os.path.join(c, "hooks"), "hooks"),
376
+ hook_rows(_json(os.path.join(c, "settings.json")),
377
+ "settings.json"),
378
+ hook_rows(_json(os.path.join(repo, ".claude",
379
+ "settings.json")), "project")),
380
+ "mcp": sorted(_json(os.path.join(c, "mcp-config.json")).get("mcpServers") or {})
381
+ + ["%s (project)" % n for n in
382
+ sorted(_json(os.path.join(repo, ".mcp.json")).get("mcpServers") or {})],
383
+ "scan": [os.path.join(c, "skills")],
384
+ }
385
+ if host == "gemini":
386
+ g, h = tp.HOST_DIRS["gemini"], tp.HOME
387
+ ext = os.path.join(g, "extensions")
388
+ exts = sorted(n for n in os.listdir(ext) if os.path.isdir(os.path.join(ext, n))) \
389
+ if os.path.isdir(ext) else []
390
+ user = _json(os.path.join(g, "settings.json"))
391
+ project = _json(os.path.join(repo, ".gemini", "settings.json"))
392
+ return {
393
+ # configuration "Context Files": the global file, then the cwd up to
394
+ # the git root, then subdirectories; all concatenate
395
+ "context": [os.path.join(g, "GEMINI.md"), os.path.join(repo, "GEMINI.md")],
396
+ # cli/skills.md: workspace over user over extension; `.agents/skills`
397
+ # wins within a tier
398
+ "skills": ("first", [os.path.join(repo, ".agents", "skills"),
399
+ os.path.join(repo, ".gemini", "skills"),
400
+ os.path.join(h, ".agents", "skills"),
401
+ os.path.join(g, "skills")]
402
+ + [os.path.join(ext, n, "skills") for n in exts]),
403
+ # hooks/index.md "Configuration": project, user, system, extensions
404
+ "hooks": merge_hooks(hook_rows(project, "project"),
405
+ hook_rows(user, "settings.json"),
406
+ *(hook_rows(_json(os.path.join(ext, n, "hooks", "hooks.json")),
407
+ "extension %s" % n) for n in exts)),
408
+ "mcp": sorted(user.get("mcpServers") or {})
409
+ + ["%s (project)" % n for n in sorted(project.get("mcpServers") or {})],
410
+ "scan": [os.path.join(g, "skills"), os.path.join(ext, "tezgah", "skills")],
411
+ }
412
+ if host == "antigravity":
413
+ a = tp.HOST_DIRS["antigravity"]
414
+ g = os.path.dirname(a)
415
+
416
+ def named(path, source):
417
+ # hooks.json maps a hook name to its events; a name set
418
+ # `enabled: false` loads nothing (docs/hooks "Hook definition fields")
419
+ data = _json(path)
420
+ return merge_hooks(*(hook_rows({"hooks": d}, "%s %s" % (source, n))
421
+ for n, d in sorted(data.items())
422
+ if isinstance(d, dict) and d.get("enabled") is not False))
423
+ return {
424
+ # docs/rules "Antigravity CLI": global and workspace rules all load
425
+ "context": [os.path.join(g, "GEMINI.md"), os.path.join(g, "AGENTS.md"),
426
+ os.path.join(repo, "GEMINI.md"), os.path.join(repo, "AGENTS.md")],
427
+ # docs/skills "CLI skill locations" names no winner
428
+ "skills": ("all", [os.path.join(repo, ".agents", "skills"),
429
+ os.path.join(a, "skills")]),
430
+ # docs/hooks "Antigravity CLI": workspace, global, plugins
431
+ "hooks": merge_hooks(named(os.path.join(repo, ".agents", "hooks.json"), "project"),
432
+ named(os.path.join(g, "config", "hooks.json"), "hooks.json")),
433
+ "mcp": sorted(_json(os.path.join(g, "config", "mcp_config.json"))
434
+ .get("mcpServers") or {})
435
+ + ["%s (project)" % n for n in sorted(
436
+ _json(os.path.join(repo, ".agents", "mcp_config.json")).get("mcpServers") or {})],
437
+ "scan": [os.path.join(a, "skills")],
438
+ }
439
+ if host == "goose":
440
+ c, h = tp.HOST_DIRS["goose"], tp.HOME
441
+ disabled = set(_json(os.path.join(c, "settings.json")).get("disabledPlugins") or [])
442
+
443
+ def plugins(base, source):
444
+ # plugins guide "Plugin Locations"; `disabledPlugins` skips one
445
+ names = sorted(n for n in os.listdir(base)
446
+ if os.path.isdir(os.path.join(base, n))) if os.path.isdir(base) else []
447
+ return [hook_rows(_json(os.path.join(base, n, "hooks", "hooks.json")),
448
+ "%s %s" % (source, n)) for n in names if n not in disabled]
449
+ return {
450
+ # goosehints guide: CONTEXT_FILE_NAMES (default AGENTS.md, .goosehints)
451
+ # global in ~/.config/goose, then the project's; all load
452
+ "context": [os.path.join(c, "AGENTS.md"), os.path.join(c, ".goosehints"),
453
+ os.path.join(repo, "AGENTS.md"), os.path.join(repo, ".goosehints")],
454
+ # skills guide "Skill Locations" names no winner; a plugin's skills are
455
+ # namespaced `<plugin>:<name>`, so they collide with none of these
456
+ "skills": ("all", [os.path.join(h, ".agents", "skills"),
457
+ os.path.join(repo, ".agents", "skills"),
458
+ os.path.join(repo, ".goose", "skills"),
459
+ os.path.join(repo, ".claude", "skills"),
460
+ os.path.join(h, ".claude", "skills")]),
461
+ # hooks guide "Where Hooks Live": user and project plugins
462
+ "hooks": merge_hooks(*plugins(os.path.join(h, ".agents", "plugins"), "plugin"),
463
+ *plugins(os.path.join(repo, ".agents", "plugins"),
464
+ "project plugin")),
465
+ # config-files guide: `extensions:` in config.yaml
466
+ "mcp": yaml_keys(_read(os.path.join(c, "config.yaml")), "extensions"),
467
+ "scan": [os.path.join(tp.GOOSE_PLUGIN, "skills")],
468
+ }
469
+ if host == "amp":
470
+ c, h = tp.HOST_DIRS["amp"], tp.HOME
471
+ system = os.path.dirname(tp.AMP_PLUGIN)
472
+
473
+ def plugins(base):
474
+ # plugins guide: a `.ts`/`.js` file or `<name>/index.*` in a plugin dir
475
+ return sorted(os.path.join(base, n) for n in (
476
+ os.listdir(base) if os.path.isdir(base) else []) if not n.startswith("."))
477
+ found = plugins(os.path.join(repo, ".amp", "plugins")) + plugins(system)
478
+ return {
479
+ # agents-md guide: the cwd and its parents, then both user files; all load
480
+ "context": [os.path.join(repo, "AGENTS.md"), os.path.join(c, "AGENTS.md"),
481
+ os.path.join(h, ".config", "AGENTS.md")],
482
+ # skills guide "Skill Sources and Precedence": the first name wins
483
+ "skills": ("first", [os.path.join(h, ".config", "agents", "skills"),
484
+ os.path.join(h, ".agents", "skills"),
485
+ os.path.join(c, "skills"),
486
+ os.path.join(repo, ".agents", "skills"),
487
+ os.path.join(repo, ".claude", "skills"),
488
+ os.path.join(h, ".claude", "skills")]),
489
+ # a plugin gets every event; a project plugin wins over a system one
490
+ # of the same name (plugins guide "Plugin Locations and Precedence")
491
+ "hooks": {"plugin (every event)": [(owner(p), p, p) for p in found]},
492
+ # settings guide: `amp.mcpServers`, user then workspace
493
+ "mcp": sorted(_json(os.path.join(c, "settings.json")).get("amp.mcpServers") or {})
494
+ + ["%s (project)" % n for n in sorted(
495
+ _json(os.path.join(repo, ".amp", "settings.json")).get("amp.mcpServers") or {})],
496
+ "scan": [os.path.join(c, "skills"), system],
497
+ }
267
498
  raise ValueError(host)
268
499
 
269
500
 
@@ -280,7 +511,11 @@ def skill_copies(dirs):
280
511
 
281
512
  def tezgah_skill_dirs():
282
513
  """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")]
514
+ dirs = [os.path.join(tp.HOST_DIRS[x], "skills")
515
+ for x in ("codex", "cursor", "opencode", "dsh", "qwen", "droid", "commandcode",
516
+ "copilot", "antigravity", "amp")]
517
+ dirs.append(os.path.join(tp.HOST_DIRS["gemini"], "extensions", "tezgah", "skills"))
518
+ dirs.append(os.path.join(tp.GOOSE_PLUGIN, "skills"))
284
519
  dirs.append(os.path.join(tp.HOST_DIRS["omp"], "agent", "skills"))
285
520
  active, _ = claude_plugin()
286
521
  return dirs + ([os.path.join(active, "skills")] if active else [])
@@ -986,7 +986,8 @@ def set_section(path, heading, body):
986
986
  # These functions shell out to git; none of them is on the gate's write path.
987
987
 
988
988
  SLICE_KINDS = ("agent", "job")
989
- SLICE_HOSTS = ("omp", "claude", "codex", "cursor", "opencode", "dsh")
989
+ SLICE_HOSTS = ("omp", "claude", "codex", "cursor", "opencode", "dsh", "qwen", "droid",
990
+ "commandcode", "copilot", "gemini", "antigravity", "goose", "amp")
990
991
  # `stale`: an idle background job whose result stays in its parent transcript.
991
992
  # An agent with no observed end stays owed as stopped with no failure class.
992
993
  SLICE_STATUSES = ("open", "done", "stopped", "resumed", "stale")
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env python3
2
+ """Amp lifecycle hook: the python half of tezgah's Amp plugin.
3
+
4
+ Amp's plugin API is TypeScript (ampcode.com/docs plugin-api), so
5
+ `hosts/amp/tezgah-plugin.ts.in`, rendered to `$XDG_CONFIG_HOME/amp/plugins/
6
+ tezgah.ts`, forwards each event here as one JSON payload on stdin and turns the
7
+ answer into Amp's own result. The protocol is omp's (hosts/omp/hook.py); the
8
+ session id is Amp's thread id.
9
+
10
+ {"event": "session_start", "cwd", "session_id", "subagent", "parent",
11
+ "ignore_guidance", "line"}
12
+ -> {"context": <live state, the core too when AGENTS.md lacks it>,
13
+ "status", "idx"}
14
+ {"event": "status", "cwd", "session_id"} -> {"status", "idx"}
15
+ {"event": "user_prompt", "cwd", "session_id", "prompt", "subagent"}
16
+ -> {"context": <reminder + the rules this prompt arms>}
17
+ {"event": "pre_tool_use", "cwd", "session_id", "tool", "input"} -> {"deny": reason}
18
+ {"event": "post_tool_use", "cwd", "session_id", "tool", "input", "status",
19
+ "exit_code", "result_len", "empty_run", "idx", "line"}
20
+ -> {"label": one line, "status", "idx"}
21
+ {"event": "stop", "cwd", "session_id", "last_assistant_message",
22
+ "stop_hook_active"} -> {"decision": "block", "reason": reason}
23
+ {"event": "subagent_stop", ..., "parent", "agent"} -> {} (record-only)
24
+
25
+ Session start cannot inject in Amp, so the bridge asks `session_start` on the
26
+ thread's next `agent.start` and sends the brief with that turn. The always-on
27
+ core is the managed block in `~/.config/amp/AGENTS.md`; the brief repeats it
28
+ only while that block is missing or AMP_IGNORE_GUIDANCE_FILES is set. The
29
+ status line is plain text: Amp's status item draws no ANSI.
30
+ """
31
+ import json
32
+ import os
33
+ import sys
34
+
35
+ ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
36
+ sys.path.insert(0, os.path.join(ROOT, "hooks"))
37
+ from tezgah_guard import attest_session, import_failed, safe # noqa: E402
38
+ try:
39
+ from tezgah_context import ( # noqa: E402
40
+ command_text, context_for, health_segments, record, render_tiers, shell_kind,
41
+ skill_read_kind)
42
+ from tezgah_gate import cross_project_note, decision # noqa: E402
43
+ from tezgah_integrity import ( # noqa: E402
44
+ SUBAGENT_CHANNEL, bind_session, note, note_tool, stop_reason, untrusted_label,
45
+ untrusted_source)
46
+ from tezgah_paths import HOST_DIRS, off, root_for # noqa: E402
47
+ from tezgah_taste import write_note # noqa: E402
48
+ from tezgah_untrusted import marks # noqa: E402
49
+ except Exception as exc:
50
+ import_failed(exc)
51
+
52
+ # Amp's tool names -> the gate's vocabulary. `shell_command` is the other shell
53
+ # tool `shellCommandFromToolCall` reads (plugin-api); `Bash`, `edit_file`,
54
+ # `create_file`, `apply_patch` and `Task` are already the gate's names, and
55
+ # an MCP tool already arrives as `mcp__<server>__<tool>`.
56
+ GATE_TOOLS = {"shell_command": "Bash"}
57
+ IDX_GLYPHS = ("\u2713", "\u21bb", "\u2717", "?", "\u2013")
58
+
59
+
60
+ def gate_name(name):
61
+ """The tool name the gate denies under and the ledger row records; both
62
+ halves map through here so a call hashes to one id."""
63
+ return GATE_TOOLS.get(name, name)
64
+
65
+
66
+ def gate_input(tool, inp):
67
+ """The input in the gate's fields: the gate reads a shell call's text only
68
+ from `command`, so Amp's `cmd` is copied there when the bridge sent none."""
69
+ inp = dict(inp) if isinstance(inp, dict) else {}
70
+ if tool == "Bash" and "command" not in inp and isinstance(inp.get("cmd"), str):
71
+ inp["command"] = inp["cmd"]
72
+ return inp
73
+
74
+
75
+ def classify(tool, inp):
76
+ """The used-tool kind for a mapped call, or None."""
77
+ if tool.startswith("mcp__") and "codegraph" in tool:
78
+ return "graph"
79
+ kind = skill_read_kind(tool, inp)
80
+ if kind:
81
+ return kind
82
+ if tool.lower() == "task":
83
+ return "orch"
84
+ if tool == "Bash":
85
+ return shell_kind(command_text(inp))
86
+ return None
87
+
88
+
89
+ def call_failed(tool, status, exit_code):
90
+ """`note_tool`'s `failed`, tri-state. An error or cancelled result is a
91
+ failure; a shell result passes or fails only on an integer exit code the
92
+ bridge read off the output object; anything else is None - a call that
93
+ ran, never a pass (a `done` shell result alone proves nothing)."""
94
+ if status in ("error", "cancelled"):
95
+ return True
96
+ if tool == "Bash" and isinstance(exit_code, int) and not isinstance(exit_code, bool):
97
+ return exit_code != 0
98
+ return None
99
+
100
+
101
+ def rules_carry_core():
102
+ """True when Amp's user AGENTS.md holds tezgah-setup's managed block."""
103
+ try:
104
+ with open(os.path.join(HOST_DIRS["amp"], "AGENTS.md"), encoding="utf-8") as fh:
105
+ return "<!-- tezgah:start" in fh.read()
106
+ except OSError:
107
+ return False
108
+
109
+
110
+ def status_line(cwd, session_id, idx=None):
111
+ """{status, idx}: the widest plain tier of the checklist. `idx` echoed back
112
+ skips the git probe behind that mark, as on omp."""
113
+ segs = health_segments(cwd, session_id,
114
+ idx_override=idx if idx in IDX_GLYPHS else None)
115
+ out = {"status": render_tiers(segs, color=False)[0][0]}
116
+ glyph = next((s["glyph"] for s in segs if s["key"] == "idx"), None)
117
+ if glyph:
118
+ out["idx"] = glyph
119
+ return out
120
+
121
+
122
+ def post_tool(payload, cwd, session_id):
123
+ """The ledger row for one finished call; {label} for the model."""
124
+ tool = gate_name(str(payload.get("tool") or ""))
125
+ inp = gate_input(tool, payload.get("input"))
126
+ failed = call_failed(tool, payload.get("status"), payload.get("exit_code"))
127
+ # read before this call's row lands: `source` is the taint's own mark; the
128
+ # body never crosses, so a non-None stand-in keeps the subagent label
129
+ own = untrusted_source(tool, inp)
130
+ if failed is True:
131
+ source, label = own, untrusted_label(own)
132
+ else:
133
+ source, label = safe(session_id, marks, tool, inp, session_id, "") or (None, None)
134
+ record(session_id, classify(tool, inp))
135
+ size = payload.get("result_len")
136
+ if own == SUBAGENT_CHANNEL or not isinstance(size, int) or size < 0:
137
+ size = None
138
+ note_tool(session_id, tool, inp, failed=failed, out_bytes=size, source=source,
139
+ cwd=cwd, empty_run=payload.get("empty_run") is True)
140
+ if failed is not True:
141
+ taste = safe(session_id, write_note, session_id, inp, cwd, tool)
142
+ cross = safe(session_id, cross_project_note, session_id, tool, inp, cwd)
143
+ label = "\n".join(t for t in (label, taste, cross) if t)
144
+ out = status_line(cwd, session_id, payload.get("idx")) if payload.get("line") else {}
145
+ if label:
146
+ out["label"] = label
147
+ return out
148
+
149
+
150
+ def handle(payload):
151
+ event = payload.get("event") or ""
152
+ cwd = payload.get("cwd") or os.getcwd()
153
+ session_id = payload.get("session_id")
154
+ safe(session_id, bind_session, session_id) # the switch latch (tezgah_paths.off)
155
+ if event == "status":
156
+ return status_line(cwd, session_id)
157
+ if event == "pre_tool_use":
158
+ tool = gate_name(str(payload.get("tool") or ""))
159
+ reason = decision(tool, gate_input(tool, payload.get("input")), cwd, session_id)
160
+ return {"deny": reason} if reason else {}
161
+ if not root_for(cwd):
162
+ return {}
163
+ if event == "session_start":
164
+ sub = bool(payload.get("subagent"))
165
+ if sub and payload.get("parent"):
166
+ # the child's ledger opens with its parent, as on omp
167
+ note(session_id, "spawned", "", parent=str(payload["parent"]),
168
+ agent=payload.get("agent"))
169
+ elif not sub:
170
+ safe(session_id, attest_session, "amp", session_id, cwd)
171
+ core = not rules_carry_core() or bool(payload.get("ignore_guidance"))
172
+ context = context_for("subagent_start" if sub else "session_start", cwd,
173
+ dict(payload, host="amp"), with_core=core)
174
+ out = status_line(cwd, session_id) if payload.get("line") else {}
175
+ if context:
176
+ out["context"] = context
177
+ return out
178
+ if event == "user_prompt":
179
+ context = context_for("user_prompt", cwd, dict(payload, host="amp"))
180
+ return {"context": context} if context else {}
181
+ if event == "post_tool_use":
182
+ return post_tool(payload, cwd, session_id)
183
+ if event == "stop":
184
+ if off("verify-off"):
185
+ return {}
186
+ # `stop_hook_active`: this turn was started by tezgah's own continue, so
187
+ # the core may refuse it once more at most (tezgah_integrity, plan 083)
188
+ reason = safe(session_id, stop_reason, payload.get("last_assistant_message"),
189
+ session_id, cwd=cwd,
190
+ after_block=bool(payload.get("stop_hook_active")),
191
+ mode=payload.get("permission_mode"))
192
+ return {"decision": "block", "reason": reason} if reason else {}
193
+ if event == "subagent_stop":
194
+ # record-only (ADR 011): the row lands in the parent's ledger
195
+ if not off("verify-off") and payload.get("parent"):
196
+ safe(session_id, stop_reason, payload.get("last_assistant_message"),
197
+ session_id, cwd=cwd, subagent=True, agent=payload.get("agent"),
198
+ parent=str(payload["parent"]))
199
+ return {}
200
+ return {}
201
+
202
+
203
+ def main():
204
+ try:
205
+ payload = json.load(sys.stdin)
206
+ except Exception:
207
+ return
208
+ if not isinstance(payload, dict):
209
+ return
210
+ # one guard around the dispatch: a crash answers "nothing to say", and the
211
+ # plugin lets every call through (hooks/tezgah_guard.safe)
212
+ out = safe(payload.get("session_id"), handle, payload) or {}
213
+ if out:
214
+ print(json.dumps(out))
215
+
216
+
217
+ main()