@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.
- package/CHANGELOG.md +333 -1
- package/MANIFEST +22 -0
- package/README.md +4 -3
- package/bin/tezgah-docs +1 -1
- package/bin/tezgah-research +39 -4
- package/bin/tezgah-setup +1229 -22
- package/bin/tezgah-task +55 -17
- package/docs/citations-baseline.json +1 -1
- package/docs/clarity-baseline.json +9 -3
- package/docs/contract.md +6 -3
- package/docs/environment.md +3 -2
- package/docs/evidence.md +81 -8
- package/docs/gate.md +6 -2
- package/docs/glossary.md +1 -1
- package/docs/hosts.md +297 -15
- package/docs/layers.md +1 -1
- package/docs/models.md +6 -0
- package/docs/operations.md +16 -5
- package/docs/research.md +36 -10
- package/docs/status-line.md +6 -0
- package/docs/testing.md +3 -1
- package/hooks/hooks.json +1 -1
- package/hooks/lang/en.json +11 -0
- package/hooks/lang/tr.json +11 -0
- package/hooks/projects-stop.py +2 -1
- package/hooks/tezgah_agents.py +27 -3
- package/hooks/tezgah_attest.py +79 -10
- package/hooks/tezgah_context.py +21 -8
- package/hooks/tezgah_gate.py +79 -23
- package/hooks/tezgah_integrity.py +212 -38
- package/hooks/tezgah_paths.py +43 -2
- package/hooks/tezgah_policy.py +35 -13
- package/hooks/tezgah_replay.py +8 -2
- package/hooks/tezgah_research.py +167 -24
- package/hooks/tezgah_stack.py +236 -1
- package/hooks/tezgah_task.py +2 -1
- package/hosts/amp/hook.py +217 -0
- package/hosts/amp/tezgah-plugin.ts.in +386 -0
- package/hosts/antigravity/hook.py +255 -0
- package/hosts/codex/hook.py +2 -1
- package/hosts/commandcode/hook.py +239 -0
- package/hosts/copilot/hook.py +284 -0
- package/hosts/cursor/hook.py +2 -1
- package/hosts/droid/hook.py +259 -0
- package/hosts/dsh/hooks.json +1 -1
- package/hosts/gemini/hook.py +213 -0
- package/hosts/goose/hook.py +145 -0
- package/hosts/omp/hook.py +4 -1
- package/hosts/omp/tezgah-hook.ts.in +30 -7
- package/hosts/opencode/plugins/tezgah.js +2 -2
- package/hosts/qwen/hook.py +206 -0
- package/output-styles/tezgah.md +10 -5
- package/package.json +1 -1
- package/skills/harness/SKILL.md +2 -0
- package/skills/i-have-adhd/SKILL.md +10 -1
- package/skills/plan-add/SKILL.md +5 -1
- package/skills/research/SKILL.md +22 -9
- package/skills/tezgah-contract/SKILL.md +19 -6
- package/statusline.py +20 -9
- package/workflows/graph-review.js +14 -2
package/hooks/tezgah_stack.py
CHANGED
|
@@ -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")
|
|
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 [])
|
package/hooks/tezgah_task.py
CHANGED
|
@@ -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()
|