@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
package/bin/tezgah-setup CHANGED
@@ -166,6 +166,28 @@ DSH_WEB_PATCH = os.path.join(DSH_WEB, "cordis.patch.yml")
166
166
  DSH_STATUSLINE = "tezgah-dsh-statusline"
167
167
  OMP = tp.HOST_DIRS["omp"]
168
168
  OMP_AGENT = os.path.join(OMP, "agent")
169
+ QWEN = tp.HOST_DIRS["qwen"]
170
+ DROID = tp.HOST_DIRS["droid"]
171
+ COMMANDCODE = tp.HOST_DIRS["commandcode"]
172
+ COPILOT = tp.HOST_DIRS["copilot"]
173
+ GEMINI = tp.HOST_DIRS["gemini"]
174
+ # tezgah's own Gemini extension: a dir Gemini loads from <home>/.gemini/extensions
175
+ # with no `gemini extensions install` (extension-manager.ts reads every subdir;
176
+ # only a non-empty `security.allowedExtensions` asks for install metadata)
177
+ GEMINI_EXT = os.path.join(GEMINI, "extensions", "tezgah")
178
+ ANTIGRAVITY = tp.HOST_DIRS["antigravity"]
179
+ # Antigravity's global hooks file: one top-level name per hook set, and the
180
+ # Antigravity App and IDE load it too (docs/hooks); tezgah owns the `tezgah` name
181
+ AGY_HOOKS = os.path.join(os.path.dirname(ANTIGRAVITY), "config", "hooks.json")
182
+ GOOSE = tp.HOST_DIRS["goose"]
183
+ # tezgah's own Goose plugin under ~/.agents/plugins, wholly tezgah's
184
+ GOOSE_PLUGIN = tp.GOOSE_PLUGIN
185
+ AMP = tp.HOST_DIRS["amp"]
186
+ # tezgah's Amp system plugin, one file Amp loads with every other .ts there
187
+ AMP_PLUGIN = tp.AMP_PLUGIN
188
+ # Amp's commit trailers, both on by default (docs cli/settings): the co-author
189
+ # line and the thread link; tezgah turns both off unless the user set them
190
+ AMP_ATTRIBUTION = ("amp.git.commit.coauthor.enabled", "amp.git.commit.ampThread.enabled")
169
191
  OMP_MCP_SCHEMA = ("https://raw.githubusercontent.com/can1357/oh-my-pi/main/"
170
192
  "packages/coding-agent/src/config/mcp-schema.json")
171
193
  # App-analysis servers omp drops on its own. While its native `browser` (the eval
@@ -223,7 +245,8 @@ OPENCODE_CLI_PLUGIN = os.path.join(OPENCODE, "tui-plugins", "tezgah-cli")
223
245
 
224
246
 
225
247
  WORKFLOW_FILES = ["graph-map.js", "graph-review.js", "graph-impact.js"]
226
- ALL_HOSTS = ["claude", "codex", "opencode", "cursor", "dsh", "omp"]
248
+ ALL_HOSTS = ["claude", "codex", "opencode", "cursor", "dsh", "omp", "qwen", "droid",
249
+ "commandcode", "copilot", "gemini", "antigravity", "goose", "amp"]
227
250
  # orx ships an installable skill shim for four agents; dsh has no orx harness,
228
251
  # so research routing there falls back to `orx skill` on the shell. omp has no
229
252
  # orx target of its own, and needs none: orx's `codex` target writes its shim to
@@ -324,6 +347,73 @@ CURSOR_HOOK_EVENTS = {
324
347
  "afterFileEdit": {"timeout": 10},
325
348
  "afterAgentResponse": {"timeout": 5},
326
349
  }
350
+ # Qwen Code 0.21.13: a matcher is a regex over the runtime tool id, and a
351
+ # command hook's `timeout` is in MILLISECONDS (60000 by default) - the seconds
352
+ # value the other manifests carry would time every hook out. Stop and
353
+ # SubagentStop run the integrity rule, which may ask a judge, so they get more.
354
+ # Qwen takes MCP tools as `mcp__<server>__<tool>`.
355
+ QWEN_PRETOOL_MATCHER = ("^(run_shell_command|monitor|write_file|edit|replace|notebook_edit|"
356
+ "agent|grep_search|mcp__.*)$")
357
+ QWEN_HOOK_EVENTS = (("SessionStart", None, 5000), ("UserPromptSubmit", None, 5000),
358
+ ("PreToolUse", QWEN_PRETOOL_MATCHER, 5000),
359
+ ("PostToolUse", None, 5000), ("PostToolUseFailure", None, 5000),
360
+ ("SubagentStart", None, 5000), ("SubagentStop", None, 30000),
361
+ ("Stop", None, 30000))
362
+ # Factory Droid 0.251.0 (docs.factory.com harness/hooks): a matcher is a
363
+ # case-sensitive regex over the tool id, and `timeout` is in seconds. Droid has
364
+ # no SubagentStart and no failure event. MCP tools are `mcp__<server>__<tool>`.
365
+ DROID_PRETOOL_MATCHER = "^(Execute|Create|Edit|ApplyPatch|Task|Grep|mcp__.*)$"
366
+ DROID_HOOK_EVENTS = (("SessionStart", None, 5), ("UserPromptSubmit", None, 5),
367
+ ("PreToolUse", DROID_PRETOOL_MATCHER, 5), ("PostToolUse", None, 5),
368
+ ("SubagentStop", None, 30), ("Stop", None, 30))
369
+ # Command Code (commandcode.ai/docs/hooks): four events, `timeout` in seconds, and
370
+ # a matcher tested against the tool's display name (SHELL, READ, WRITE, EDIT) -
371
+ # its settings hooks see no other tool, so MCP, subagent and web calls are not
372
+ # gated there. A matcher on SessionStart or Stop stops the hook from firing.
373
+ COMMANDCODE_PRETOOL_MATCHER = "shell|write|edit"
374
+ COMMANDCODE_HOOK_EVENTS = (("SessionStart", None, 5),
375
+ ("PreToolUse", COMMANDCODE_PRETOOL_MATCHER, 5),
376
+ ("PostToolUse", None, 5), ("Stop", None, 30))
377
+ # GitHub Copilot CLI (docs.github.com hooks-reference): tezgah's own file in the
378
+ # user hooks dir, flat entries with `timeoutSec` in seconds. The PascalCase names
379
+ # get snake_case payloads and Claude tool names; `subagentStart` has no PascalCase
380
+ # form, so its camelCase payload names no event and the command passes it. A
381
+ # PascalCase PreToolUse matcher is anchored and tested against the Claude tool
382
+ # name; an MCP tool is `<server>-<tool>`. A timed-out PreToolUse lets the call
383
+ # through, hence 10 s there.
384
+ COPILOT_PRETOOL_MATCHER = "^(?:Bash|Write|Edit|Agent|Task|Grep|.+-.+)$"
385
+ COPILOT_HOOK_EVENTS = (("SessionStart", None, 5), ("UserPromptSubmit", None, 5),
386
+ ("PreToolUse", COPILOT_PRETOOL_MATCHER, 10), ("PostToolUse", None, 5),
387
+ ("PostToolUseFailure", None, 5), ("subagentStart", None, 5),
388
+ ("SubagentStop", None, 30), ("Stop", None, 30))
389
+ # Gemini CLI (geminicli hooks reference): `timeout` in MILLISECONDS, a BeforeTool
390
+ # matcher is a regex over the tool name, a lifecycle matcher an exact string (so
391
+ # none). A subagent is a tool of its own name; an MCP tool is
392
+ # `mcp_<server>_<tool>`. AfterAgent runs the integrity rule, which may ask a judge.
393
+ GEMINI_PRETOOL_MATCHER = ("^(run_shell_command|write_file|replace|grep_search|"
394
+ "search_file_content|codebase_investigator|generalist|"
395
+ "invoke_agent|mcp_.*)$")
396
+ GEMINI_HOOK_EVENTS = (("SessionStart", None, 5000), ("BeforeAgent", None, 5000),
397
+ ("BeforeTool", GEMINI_PRETOOL_MATCHER, 5000),
398
+ ("AfterTool", None, 5000), ("AfterAgent", None, 30000))
399
+ # Antigravity CLI (docs/hooks): `timeout` in seconds; a tool event is a matcher
400
+ # group (a regex over the tool name, "" for every tool), a lifecycle event a bare
401
+ # handler. The payload names no event, so the command does (argv[1]). An MCP
402
+ # tool's name is undocumented, so none is matched. Stop runs the integrity rule.
403
+ ANTIGRAVITY_PRETOOL_MATCHER = ("^(run_command|write_to_file|replace_file_content|"
404
+ "multi_replace_file_content|invoke_subagent|grep_search)$")
405
+ ANTIGRAVITY_HOOK_EVENTS = (("SessionStart", None, 5), ("PreInvocation", None, 5),
406
+ ("PreToolUse", ANTIGRAVITY_PRETOOL_MATCHER, 5),
407
+ ("PostToolUse", "", 5), ("Stop", None, 30))
408
+ # Goose (docs guides/context-engineering/hooks): `timeout` in seconds, a matcher a
409
+ # regex tested unanchored (so anchored here) against the tool name. The built-in
410
+ # developer tools are bare (`shell`, `write`, `edit`), an extension's tool is
411
+ # `<extension>__<tool>`. Only PreToolUse and Stop can block; Stop runs the
412
+ # integrity rule. `on_failure` is left at its default `allow` (X-D2).
413
+ GOOSE_PRETOOL_MATCHER = "^(shell|write|edit|.+__.+)$"
414
+ GOOSE_HOOK_EVENTS = (("SessionStart", None, 5), ("UserPromptSubmit", None, 5),
415
+ ("PreToolUse", GOOSE_PRETOOL_MATCHER, 5), ("PostToolUse", None, 5),
416
+ ("PostToolUseFailure", None, 5), ("Stop", None, 30))
327
417
  # The feature selection for THIS run: None means "read config.json['features']",
328
418
  # and an absent key there means the registry's declared default, which is what
329
419
  # makes a config written before this existed behave exactly as it used to.
@@ -468,14 +558,22 @@ def hook_entries(hosts):
468
558
  what re-arming would change."""
469
559
  out = {}
470
560
  for h in hosts:
471
- if h in ("codex", "cursor"):
472
- groups = codex_hook_groups() if h == "codex" else cursor_hook_entries()
561
+ if h in ("codex", "cursor", "qwen", "droid", "commandcode", "copilot", "gemini",
562
+ "antigravity", "goose"):
563
+ groups = {"codex": codex_hook_groups, "cursor": cursor_hook_entries,
564
+ "qwen": qwen_hook_groups, "droid": droid_hook_groups,
565
+ "commandcode": commandcode_hook_groups,
566
+ "copilot": copilot_hook_entries, "gemini": gemini_hook_groups,
567
+ "antigravity": antigravity_hook_groups,
568
+ "goose": goose_hook_groups}[h]()
473
569
  out[h] = ta.own_groups({"hooks": {ev: [g] for ev, g in groups.items()}})
474
570
  elif h == "opencode":
475
571
  src = os.path.join(HERE, "hosts", "opencode", "plugins", "tezgah.js")
476
572
  out[h] = {d: src for d in ta.OPENCODE_DIRS}
477
573
  elif h == "omp":
478
574
  out[h] = {"bridge": omp_bridge()}
575
+ elif h == "amp":
576
+ out[h] = {"plugin": ta.amp_plugin(HERE)}
479
577
  else:
480
578
  out[h] = ta.registration(h, HERE)[0] or {}
481
579
  return out
@@ -679,7 +777,13 @@ def write_json(path, data):
679
777
  An unchanged write is not free: it moves a host file's mtime and lays down a
680
778
  `.tezgah-bak`, so a command whose whole point is idempotence (a repeated
681
779
  --disable) would report a change it never made. Returns True when it wrote."""
682
- text = json.dumps(data, indent=2) + "\n"
780
+ return write_text(path, json.dumps(data, indent=2) + "\n")
781
+
782
+
783
+ def write_text(path, text):
784
+ """write_json's write for text already rendered (a JSON-with-comments file
785
+ edited in place): unchanged bytes skip it, a backup comes first, and the
786
+ file keeps its mode. Returns True when it wrote."""
683
787
  try:
684
788
  with open(path, encoding="utf-8") as fh:
685
789
  if fh.read() == text:
@@ -689,8 +793,15 @@ def write_json(path, data):
689
793
  mkdirp(os.path.dirname(path))
690
794
  backup(path)
691
795
  tmp = path + ".tmp"
692
- with open(tmp, "w", encoding="utf-8") as fh:
796
+ existed = os.path.exists(path)
797
+ # over an existing file the copy is born owner-only, then takes the file's
798
+ # mode: a settings file holding provider keys is never readable by others,
799
+ # not even between the write and the mode copy
800
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600 if existed else 0o666)
801
+ with open(fd, "w", encoding="utf-8") as fh:
693
802
  fh.write(text)
803
+ if existed:
804
+ shutil.copymode(path, tmp) # an owner-only config stays owner-only
694
805
  os.replace(tmp, path)
695
806
  return True
696
807
 
@@ -727,7 +838,8 @@ def backup_base(path):
727
838
 
728
839
  def backup(path):
729
840
  """Copy `path` to `<backup_base>.<timestamp>.tezgah-bak` before a write
730
- changes it.
841
+ changes it, owner-only: a host config can hold provider keys (Qwen's
842
+ settings.json does), and a copy must not be readable where the file was not.
731
843
 
732
844
  The suffix stays `.tezgah-bak`, so managed() and the uninstall cleanup still
733
845
  know it; past BACKUP_CAP the middle copies go, never the oldest."""
@@ -743,6 +855,7 @@ def backup(path):
743
855
  try:
744
856
  mkdirp(os.path.dirname(dst))
745
857
  shutil.copy2(path, dst)
858
+ os.chmod(dst, 0o600)
746
859
  d, name = os.path.split(base)
747
860
  old = sorted(entry for entry in os.listdir(d or ".")
748
861
  if (m := BACKUP_RE.fullmatch(entry)) and m.group(1) == name)
@@ -1098,6 +1211,15 @@ def install_common(new_roots, hosts):
1098
1211
  ("tezgah-render-table", os.path.join(HERE, "skills", "plan-add", "render_table.py")),
1099
1212
  ("tezgah-codex-hook", os.path.join(HERE, "hosts", "codex", "hook.py")),
1100
1213
  ("tezgah-cursor-hook", os.path.join(HERE, "hosts", "cursor", "hook.py")),
1214
+ ("tezgah-qwen-hook", os.path.join(HERE, "hosts", "qwen", "hook.py")),
1215
+ ("tezgah-droid-hook", os.path.join(HERE, "hosts", "droid", "hook.py")),
1216
+ ("tezgah-commandcode-hook",
1217
+ os.path.join(HERE, "hosts", "commandcode", "hook.py")),
1218
+ ("tezgah-copilot-hook", os.path.join(HERE, "hosts", "copilot", "hook.py")),
1219
+ ("tezgah-gemini-hook", os.path.join(HERE, "hosts", "gemini", "hook.py")),
1220
+ ("tezgah-antigravity-hook",
1221
+ os.path.join(HERE, "hosts", "antigravity", "hook.py")),
1222
+ ("tezgah-goose-hook", os.path.join(HERE, "hosts", "goose", "hook.py")),
1101
1223
  ("tezgah-statusline", os.path.join(HERE, "statusline.py")),
1102
1224
  ("tezgah-triage", os.path.join(HERE, "bin", "tezgah-triage")),
1103
1225
  ("tezgah-migrate", os.path.join(HERE, "bin", "tezgah-migrate")),
@@ -1613,7 +1735,7 @@ def opencode_contract():
1613
1735
  def contract_artifacts(hosts):
1614
1736
  """{path: (fresh render, writer)} for every always-on contract artifact
1615
1737
  these hosts read: the managed block of each static rules file (Claude,
1616
- Codex, omp) and opencode's contract and skill routers."""
1738
+ Codex, omp, Qwen, Droid) and opencode's contract and skill routers."""
1617
1739
  out = {}
1618
1740
  for h in hosts:
1619
1741
  if h in STATIC_RULES:
@@ -2087,6 +2209,470 @@ def install_cursor():
2087
2209
  restart_hint("cursor", mpath)
2088
2210
 
2089
2211
 
2212
+ def qwen_hook_groups():
2213
+ """{event: the group install_qwen writes}; `hook_entries` plans from it."""
2214
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-qwen-hook"))
2215
+ out = {}
2216
+ for ev, matcher, timeout in QWEN_HOOK_EVENTS:
2217
+ group = {"matcher": matcher} if matcher else {}
2218
+ group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
2219
+ out[ev] = group
2220
+ return out
2221
+
2222
+
2223
+ def install_qwen():
2224
+ """Qwen Code: hook groups and the status line in `settings.json`, skill
2225
+ links, and the always-on block in `QWEN.md`. settings.json is shared - Orca
2226
+ writes its own hooks there and the user keeps provider keys in it - so only a
2227
+ group naming tezgah is replaced, the test uninstall drops by, and the backup
2228
+ `write_json` lays down is owner-only (`backup`)."""
2229
+ print("\nqwen:")
2230
+ path = os.path.join(QWEN, "settings.json")
2231
+ data = read_config(path, {})
2232
+ if data is not None:
2233
+ hooks = data.setdefault("hooks", {})
2234
+ for ev, group in qwen_hook_groups().items():
2235
+ arr = hooks.setdefault(ev, [])
2236
+ arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
2237
+ arr.append(group)
2238
+ # Qwen's command status line: the session JSON on stdin, ANSI kept
2239
+ data.setdefault("ui", {})["statusLine"] = {
2240
+ "type": "command",
2241
+ "command": tp.hook_command(
2242
+ os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --qwen",
2243
+ "respectUserColors": True,
2244
+ }
2245
+ write_json(path, data)
2246
+ print("%s %s (hooks + ui.statusLine)" % (OK, path))
2247
+ for s in SKILLS:
2248
+ print(link(os.path.join(HERE, "skills", s), os.path.join(QWEN, "skills", s)))
2249
+ print("%s %s (always-on contract)" % (OK, write_rules("qwen")))
2250
+
2251
+
2252
+ def droid_hook_groups():
2253
+ """{event: the group install_droid writes}; `hook_entries` plans from it."""
2254
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-droid-hook"))
2255
+ out = {}
2256
+ for ev, matcher, timeout in DROID_HOOK_EVENTS:
2257
+ group = {"matcher": matcher} if matcher else {}
2258
+ group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
2259
+ out[ev] = group
2260
+ return out
2261
+
2262
+
2263
+ # a JSON string, kept, or a `//` / `/* */` comment, dropped
2264
+ JSONC_TOKENS = re.compile(r'("(?:\\.|[^"\\])*")|//[^\n]*|/\*.*?\*/', re.S)
2265
+
2266
+
2267
+ def read_jsonc(path):
2268
+ """The object in a JSON-with-comments file (Droid's settings.json), {} when
2269
+ the file is absent, None when it does not parse. A whole rewrite would drop
2270
+ the user's comments, so the only writers are `jsonc_put`/`jsonc_drop`.
2271
+ ponytail: a trailing comma inside a string value is dropped too; the readers
2272
+ only ask for top-level keys."""
2273
+ try:
2274
+ with open(path, encoding="utf-8") as fh:
2275
+ text = fh.read()
2276
+ except FileNotFoundError:
2277
+ return {}
2278
+ except OSError:
2279
+ return None
2280
+ return jsonc_loads(text)
2281
+
2282
+
2283
+ def droid_hooks_blocked():
2284
+ """Why tezgah must not create ~/.factory/hooks.json, or None. Droid reads the
2285
+ `hooks` key of settings.json only while hooks.json is absent, so creating one
2286
+ beside such a key would switch the user's own hooks off."""
2287
+ if os.path.exists(os.path.join(DROID, "hooks.json")):
2288
+ return None
2289
+ path = os.path.join(DROID, "settings.json")
2290
+ data = read_jsonc(path)
2291
+ if data is None:
2292
+ return "%s does not parse, so its `hooks` key cannot be ruled out" % path
2293
+ if "hooks" in data:
2294
+ return ("%s carries a `hooks` key, which Droid ignores once hooks.json "
2295
+ "exists - move those hooks into hooks.json, then re-run --install" % path)
2296
+ return None
2297
+
2298
+
2299
+ def droid_statusline():
2300
+ return tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --droid"
2301
+
2302
+
2303
+ def jsonc_loads(text):
2304
+ """read_jsonc's parse of text already read: the object, or None."""
2305
+ text = re.sub(r",(\s*[}\]])", r"\1", JSONC_TOKENS.sub(lambda m: m.group(1) or "", text))
2306
+ try:
2307
+ data = json.loads(text)
2308
+ except ValueError:
2309
+ return None
2310
+ return data if isinstance(data, dict) else None
2311
+
2312
+
2313
+ # the object's opening brace, past any comment or string before it
2314
+ JSONC_OPEN = re.compile(r'("(?:\\.|[^"\\])*")|//[^\n]*|/\*.*?\*/|(\{)', re.S)
2315
+
2316
+
2317
+ def jsonc_put(text, key, value):
2318
+ """`text` (JSON with comments) with the top-level `key` set to `value` and
2319
+ every comment kept, or None when that cannot be done safely: the file does
2320
+ not parse, the key holds an object or list, or the edit would change
2321
+ anything else. An absent key goes in as one line under the opening brace,
2322
+ the shape `jsonc_drop` takes back out."""
2323
+ data = jsonc_loads(text)
2324
+ if data is None:
2325
+ return None
2326
+ want = dict(data, **{key: value})
2327
+ if key in data:
2328
+ if data[key] == value:
2329
+ return text
2330
+ if isinstance(data[key], (dict, list)):
2331
+ return None
2332
+ new = re.sub(r'("%s"\s*:\s*)%s' % (re.escape(key), re.escape(json.dumps(data[key]))),
2333
+ lambda m: m.group(1) + json.dumps(value), text, count=1)
2334
+ else:
2335
+ brace = next((m for m in JSONC_OPEN.finditer(text) if m.group(2)), None)
2336
+ if brace is None:
2337
+ return None
2338
+ line = "\n %s: %s%s" % (json.dumps(key), json.dumps(value), "," if data else "")
2339
+ new = text[:brace.end()] + line + text[brace.end():]
2340
+ return new if jsonc_loads(new) == want else None
2341
+
2342
+
2343
+ def jsonc_drop(text, key, value):
2344
+ """`text` without the one-line `key: value` member `jsonc_put` inserted, or
2345
+ None when that exact line is not there or its removal changes anything else."""
2346
+ data = jsonc_loads(text)
2347
+ if data is None or data.get(key) != value:
2348
+ return None
2349
+ new = re.sub(r'\n[ \t]*%s: %s,?' % (re.escape(json.dumps(key)), re.escape(json.dumps(value))),
2350
+ "", text, count=1)
2351
+ # the line's own comma may now sit before the closing brace (comments may
2352
+ # follow it): take it out, unless the file already carried one there
2353
+ tail = r",(\s*}(?:\s|//[^\n]*|/\*.*?\*/)*)$"
2354
+ if not re.search(tail, text, re.S):
2355
+ new = re.sub(tail, r"\1", new, flags=re.S)
2356
+ rest = dict(data)
2357
+ rest.pop(key)
2358
+ return new if jsonc_loads(new) == rest else None
2359
+
2360
+
2361
+ def install_droid():
2362
+ """Factory Droid: hook groups in `~/.factory/hooks.json`, the status line in
2363
+ `settings.json`, skill links, and the always-on block in `AGENTS.md`.
2364
+ hooks.json may hold the user's own groups, so only a group naming tezgah is
2365
+ replaced. It is not created while settings.json declares hooks
2366
+ (`droid_hooks_blocked`). settings.json is JSON with comments, so the status
2367
+ line and `includeCoAuthoredByDroid: false` (no co-author trailer: the
2368
+ no-attribution rule) are edited in place with every comment kept
2369
+ (`jsonc_put`); a file that cannot be edited safely is left alone and named."""
2370
+ print("\ndroid:")
2371
+ path = os.path.join(DROID, "hooks.json")
2372
+ why = droid_hooks_blocked()
2373
+ if why:
2374
+ print("%s %s not written: %s" % (NO, path, why))
2375
+ else:
2376
+ data = read_config(path, {})
2377
+ if data is not None:
2378
+ for ev, group in droid_hook_groups().items():
2379
+ arr = data.setdefault(ev, [])
2380
+ arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
2381
+ arr.append(group)
2382
+ write_json(path, data)
2383
+ print("%s %s (hooks)" % (OK, path))
2384
+ spath = os.path.join(DROID, "settings.json")
2385
+ try:
2386
+ with open(spath, encoding="utf-8") as fh:
2387
+ text = fh.read()
2388
+ except FileNotFoundError:
2389
+ text = "{}\n"
2390
+ except OSError:
2391
+ text = None
2392
+ # the command's stdout is drawn above the input (settings reference)
2393
+ line = {"command": droid_statusline()}
2394
+ try:
2395
+ plain = json.loads(text) if text is not None else None
2396
+ except ValueError:
2397
+ plain = None # comments: edit in place
2398
+ if why:
2399
+ pass # the install stopped at the user's own `hooks` key: settings stays as it is
2400
+ elif isinstance(plain, dict):
2401
+ # plain JSON (an earlier install wrote it): set both keys and rewrite, as before
2402
+ ours = "tezgah-statusline" in json.dumps(plain.get("statusLine") or "")
2403
+ if ours or "statusLine" not in plain:
2404
+ plain["statusLine"] = line
2405
+ plain["includeCoAuthoredByDroid"] = False
2406
+ write_json(spath, plain)
2407
+ print("%s %s (statusLine%s, includeCoAuthoredByDroid false)" % (
2408
+ OK, spath, "" if plain["statusLine"] == line else " left as the user set it"))
2409
+ elif text is not None:
2410
+ old = (jsonc_loads(text) or {}).get("statusLine")
2411
+ stale = old != line and "tezgah-statusline" in json.dumps(old or "")
2412
+ if stale:
2413
+ dropped = jsonc_drop(text, "statusLine", old) # an earlier install's line
2414
+ text, stale = (dropped, False) if dropped is not None else (text, True)
2415
+ # each key on its own: a user's own statusLine never blocks the trailer switch
2416
+ new = jsonc_put(text, "statusLine", line) or text
2417
+ new = jsonc_put(new, "includeCoAuthoredByDroid", False) or new
2418
+ if new != text:
2419
+ write_text(spath, new)
2420
+ done = jsonc_loads(new) or {}
2421
+ print("%s %s (statusLine%s, includeCoAuthoredByDroid %s)" % (
2422
+ OK if done.get("includeCoAuthoredByDroid") is False and not stale else NO, spath,
2423
+ "" if done.get("statusLine") == line else
2424
+ " from an earlier install could not be refreshed: set it with /statusline to `%s`"
2425
+ % line["command"] if stale else " left as the user set it",
2426
+ "false" if done.get("includeCoAuthoredByDroid") is False else "not set: edit by hand"))
2427
+ else:
2428
+ print("%s %s cannot be read: set the status line with /statusline to `%s` and "
2429
+ "`includeCoAuthoredByDroid` to false" % (NO, spath, droid_statusline()))
2430
+ for s in SKILLS:
2431
+ print(link(os.path.join(HERE, "skills", s), os.path.join(DROID, "skills", s)))
2432
+ print("%s %s (always-on contract)" % (OK, write_rules("droid")))
2433
+
2434
+
2435
+ def commandcode_hook_groups():
2436
+ """{event: the group install_commandcode writes}; `hook_entries` plans from it."""
2437
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-commandcode-hook"))
2438
+ out = {}
2439
+ for ev, matcher, timeout in COMMANDCODE_HOOK_EVENTS:
2440
+ group = {"matcher": matcher} if matcher else {}
2441
+ group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
2442
+ out[ev] = group
2443
+ return out
2444
+
2445
+
2446
+ def install_commandcode():
2447
+ """Command Code: hook groups in the `hooks` key of
2448
+ `~/.commandcode/settings.json`, skill links, and the always-on block in
2449
+ `AGENTS.md`. settings.json is the user's file, so only a group naming tezgah
2450
+ is replaced and the backup is owner-only (`backup`). The same write turns
2451
+ off the default `Co-authored-by: CommandCodeBot` commit trailer
2452
+ (`attribution.commit` "", docs/settings) unless the user set a value."""
2453
+ print("\ncommandcode:")
2454
+ path = os.path.join(COMMANDCODE, "settings.json")
2455
+ data = read_config(path, {})
2456
+ if data is not None:
2457
+ hooks = data.setdefault("hooks", {})
2458
+ for ev, group in commandcode_hook_groups().items():
2459
+ arr = hooks.setdefault(ev, [])
2460
+ arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
2461
+ arr.append(group)
2462
+ attr = data.setdefault("attribution", {})
2463
+ if isinstance(attr, dict) and "commit" not in attr:
2464
+ attr["commit"] = ""
2465
+ write_json(path, data)
2466
+ print("%s %s (hooks + attribution.commit)" % (OK, path))
2467
+ if not isinstance(attr, dict) or attr.get("commit") != "":
2468
+ print("%s %s keeps your attribution.commit, so commits still carry a "
2469
+ "co-author trailer" % (NO, path))
2470
+ for s in SKILLS:
2471
+ print(link(os.path.join(HERE, "skills", s), os.path.join(COMMANDCODE, "skills", s)))
2472
+ print("%s %s (always-on contract)" % (OK, write_rules("commandcode")))
2473
+
2474
+
2475
+ def copilot_hook_entries():
2476
+ """{event: the entry install_copilot writes}; `hook_entries` plans from it."""
2477
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-copilot-hook"))
2478
+ out = {}
2479
+ for ev, matcher, timeout in COPILOT_HOOK_EVENTS:
2480
+ entry = {"type": "command", "timeoutSec": timeout,
2481
+ # a camelCase payload names no event, so the command does
2482
+ "command": cmd + " " + ev if ev[0].islower() else cmd}
2483
+ if matcher:
2484
+ entry["matcher"] = matcher
2485
+ out[ev] = entry
2486
+ return out
2487
+
2488
+
2489
+ def copilot_statusline():
2490
+ return tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --copilot"
2491
+
2492
+
2493
+ def install_copilot():
2494
+ """GitHub Copilot CLI: `$COPILOT_HOME/hooks/tezgah.json`, a hooks file wholly
2495
+ tezgah's (the inline `hooks` key of settings.json is never used: one bad item
2496
+ there rejects every hook in it), the status line and `includeCoAuthoredBy`
2497
+ in settings.json, skill links, and the always-on block in
2498
+ `copilot-instructions.md`. settings.json is JSON with comments, so it is
2499
+ written only while it parses as plain JSON; `includeCoAuthoredBy` (default
2500
+ true, a `Co-authored-by` trailer on the agent's commits) is set false unless
2501
+ the user set it."""
2502
+ print("\ncopilot:")
2503
+ path = os.path.join(COPILOT, "hooks", "tezgah.json")
2504
+ write_json(path, {"version": 1, "hooks": {ev: [e] for ev, e in
2505
+ copilot_hook_entries().items()}})
2506
+ print("%s %s (hooks)" % (OK, path))
2507
+ spath = os.path.join(COPILOT, "settings.json")
2508
+ settings = read_json(spath, None) if os.path.exists(spath) else {}
2509
+ if isinstance(settings, dict):
2510
+ settings["statusLine"] = {"type": "command", "command": copilot_statusline()}
2511
+ settings.setdefault("includeCoAuthoredBy", False)
2512
+ write_json(spath, settings)
2513
+ print("%s %s (statusLine + includeCoAuthoredBy)" % (OK, spath))
2514
+ if settings["includeCoAuthoredBy"] is not False:
2515
+ print("%s %s keeps your includeCoAuthoredBy, so commits still carry a "
2516
+ "co-author trailer" % (NO, spath))
2517
+ else:
2518
+ print("%s %s holds comments, which a rewrite would drop: set `statusLine` to "
2519
+ "`%s` and `includeCoAuthoredBy` to false there" % (NO, spath,
2520
+ copilot_statusline()))
2521
+ for s in SKILLS:
2522
+ print(link(os.path.join(HERE, "skills", s), os.path.join(COPILOT, "skills", s)))
2523
+ print("%s %s (always-on contract)" % (OK, write_rules("copilot")))
2524
+
2525
+
2526
+ def gemini_hook_groups():
2527
+ """{event: the group install_gemini writes}; `hook_entries` plans from it."""
2528
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-gemini-hook"))
2529
+ out = {}
2530
+ for ev, matcher, timeout in GEMINI_HOOK_EVENTS:
2531
+ group = {"matcher": matcher} if matcher else {}
2532
+ group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
2533
+ out[ev] = group
2534
+ return out
2535
+
2536
+
2537
+ def install_gemini():
2538
+ """Gemini CLI: tezgah's own extension dir, `gemini-extension.json` beside
2539
+ `hooks/hooks.json` and `skills/` links, wholly tezgah's; and the always-on
2540
+ block in `GEMINI.md`. settings.json is never written: it is JSON with
2541
+ comments and holds the user's keys. Gemini's default prompt adds no
2542
+ co-author trailer and no setting names one, so there is nothing to turn off."""
2543
+ print("\ngemini:")
2544
+ write_json(os.path.join(GEMINI_EXT, "gemini-extension.json"),
2545
+ {"name": "tezgah", "version": plugin_version(),
2546
+ "description": "tezgah lifecycle hooks and skills"})
2547
+ path = os.path.join(GEMINI_EXT, "hooks", "hooks.json")
2548
+ write_json(path, {"hooks": {ev: [g] for ev, g in gemini_hook_groups().items()}})
2549
+ print("%s %s (extension hooks)" % (OK, path))
2550
+ for s in SKILLS:
2551
+ print(link(os.path.join(HERE, "skills", s), os.path.join(GEMINI_EXT, "skills", s)))
2552
+ print("%s %s (always-on contract)" % (OK, write_rules("gemini")))
2553
+
2554
+
2555
+ def antigravity_hook_groups():
2556
+ """{event: the item install_antigravity writes under hooks.json's `tezgah`
2557
+ name}: a matcher group for a tool event, a bare handler for the others
2558
+ (docs/hooks "Schema and file format"); `hook_entries` plans from it."""
2559
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-antigravity-hook"))
2560
+ out = {}
2561
+ for ev, matcher, timeout in ANTIGRAVITY_HOOK_EVENTS:
2562
+ handler = {"type": "command", "command": cmd + " " + ev, "timeout": timeout}
2563
+ out[ev] = handler if matcher is None else {"matcher": matcher, "hooks": [handler]}
2564
+ return out
2565
+
2566
+
2567
+ def antigravity_statusline():
2568
+ return tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --antigravity"
2569
+
2570
+
2571
+ def install_antigravity():
2572
+ """Antigravity CLI: tezgah's hook set under the top-level `tezgah` name of
2573
+ `~/.gemini/config/hooks.json`, beside the user's other names (hooks in
2574
+ antigravity-cli/settings.json are never loaded, docs/hooks); the Antigravity
2575
+ App and IDE read that file too. The status line in
2576
+ `~/.gemini/antigravity-cli/settings.json`, written only while it is plain
2577
+ JSON; skill links in `antigravity-cli/skills`; the always-on block in the
2578
+ `~/.gemini/GEMINI.md` Gemini CLI shares. Antigravity adds no co-author
2579
+ trailer of its own and no setting names one, so there is nothing to turn off."""
2580
+ print("\nantigravity:")
2581
+ data = read_config(AGY_HOOKS, {})
2582
+ if data is not None:
2583
+ data["tezgah"] = {ev: [g] for ev, g in antigravity_hook_groups().items()}
2584
+ write_json(AGY_HOOKS, data)
2585
+ print("%s %s (hooks; the Antigravity App and IDE load them too)" % (OK, AGY_HOOKS))
2586
+ spath = os.path.join(ANTIGRAVITY, "settings.json")
2587
+ settings = read_json(spath, None) if os.path.exists(spath) else {}
2588
+ if isinstance(settings, dict):
2589
+ settings["statusLine"] = {"type": "command", "command": antigravity_statusline()}
2590
+ write_json(spath, settings)
2591
+ print("%s %s (statusLine)" % (OK, spath))
2592
+ else:
2593
+ print("%s %s is not plain JSON, which a rewrite would lose: set `statusLine` "
2594
+ "to {\"type\": \"command\", \"command\": \"%s\"} there"
2595
+ % (NO, spath, antigravity_statusline()))
2596
+ for s in SKILLS:
2597
+ print(link(os.path.join(HERE, "skills", s), os.path.join(ANTIGRAVITY, "skills", s)))
2598
+ print("%s %s (always-on contract)" % (OK, write_rules("antigravity")))
2599
+
2600
+
2601
+ def goose_hook_groups():
2602
+ """{event: the group install_goose writes}; `hook_entries` plans from it."""
2603
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-goose-hook"))
2604
+ out = {}
2605
+ for ev, matcher, timeout in GOOSE_HOOK_EVENTS:
2606
+ group = {"matcher": matcher} if matcher else {}
2607
+ group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
2608
+ out[ev] = group
2609
+ return out
2610
+
2611
+
2612
+ def install_goose():
2613
+ """Goose: tezgah's own plugin dir, `~/.agents/plugins/tezgah/`, wholly
2614
+ tezgah's - `plugin.json` beside `hooks/hooks.json` and `skills/` links (Goose
2615
+ loads them as `tezgah:<skill>`) - and the always-on block in
2616
+ `~/.config/goose/.goosehints`. The user's other `~/.agents` content is never
2617
+ touched. `on_failure` stays at its default `allow`, so a hook fault lets the
2618
+ call through. Neither Goose's docs nor its system prompt add a co-author
2619
+ trailer or name a setting for one, so there is nothing to turn off."""
2620
+ print("\ngoose:")
2621
+ write_json(os.path.join(GOOSE_PLUGIN, "plugin.json"),
2622
+ {"name": "tezgah", "version": plugin_version(),
2623
+ "description": "tezgah lifecycle hooks and skills"})
2624
+ path = os.path.join(GOOSE_PLUGIN, "hooks", "hooks.json")
2625
+ write_json(path, {"hooks": {ev: [g] for ev, g in goose_hook_groups().items()}})
2626
+ print("%s %s (plugin hooks)" % (OK, path))
2627
+ for s in SKILLS:
2628
+ print(link(os.path.join(HERE, "skills", s), os.path.join(GOOSE_PLUGIN, "skills", s)))
2629
+ print("%s %s (always-on contract)" % (OK, write_rules("goose")))
2630
+
2631
+
2632
+ def amp_settings():
2633
+ """(path, settings dict or None): Amp's user settings.json as tezgah may
2634
+ rewrite it - absent reads as {}, and None stands for a file tezgah must not
2635
+ touch: one that is not plain JSON, or a settings.jsonc beside it (which of
2636
+ the two wins is undocumented)."""
2637
+ path = os.path.join(AMP, "settings.json")
2638
+ if os.path.exists(os.path.join(AMP, "settings.jsonc")):
2639
+ return path, None
2640
+ return path, read_json(path, None) if os.path.exists(path) else {}
2641
+
2642
+
2643
+ def install_amp():
2644
+ """Amp: one system plugin file, `$XDG_CONFIG_HOME/amp/plugins/tezgah.ts`,
2645
+ rendered from `hosts/amp/tezgah-plugin.ts.in` (Amp loads every .ts there,
2646
+ nothing registers it); skill links in `~/.config/amp/skills`; the always-on
2647
+ block in `~/.config/amp/AGENTS.md`; and Amp's two commit trailers set off
2648
+ in settings.json unless the user set them."""
2649
+ print("\namp:")
2650
+ plugin = ta.amp_plugin(HERE)
2651
+ if not plugin:
2652
+ print("%s %s: this tree has no hosts/amp/tezgah-plugin.ts.in to render"
2653
+ % (NO, AMP_PLUGIN))
2654
+ elif write_if_changed(AMP_PLUGIN, plugin):
2655
+ print("%s %s (plugin: gate + ledger + Stop rule)" % (OK, AMP_PLUGIN))
2656
+ print("%s amp: run `plugins: reload` in a running Amp to load it" % OK)
2657
+ spath, settings = amp_settings()
2658
+ if isinstance(settings, dict):
2659
+ for key in AMP_ATTRIBUTION:
2660
+ settings.setdefault(key, False)
2661
+ write_json(spath, settings)
2662
+ print("%s %s (commit trailers off)" % (OK, spath))
2663
+ for key in AMP_ATTRIBUTION:
2664
+ if settings[key] is not False:
2665
+ print("%s %s keeps your %s, so the agent's commits still carry "
2666
+ "that trailer" % (NO, spath, key))
2667
+ else:
2668
+ print("%s %s is not plain JSON (or settings.jsonc exists): set %s to false "
2669
+ "there" % (NO, spath, " and ".join("`%s`" % k for k in AMP_ATTRIBUTION)))
2670
+ for s in SKILLS:
2671
+ print(link(os.path.join(HERE, "skills", s), os.path.join(AMP, "skills", s)))
2672
+ print("%s %s (always-on contract)" % (OK, write_rules("amp")))
2673
+
2674
+
2675
+
2090
2676
  def dsh_app_mcp_rows():
2091
2677
  """One dsh-mcp-client row per MCP server tezgah writes into the block.
2092
2678
 
@@ -2471,7 +3057,32 @@ CLAUDE_RULES = os.path.join(CLAUDE, "CLAUDE.md")
2471
3057
  # and where that file is. A lambda per row, because codex_rules() answers from
2472
3058
  # the disk (AGENTS.override.md wins when it exists).
2473
3059
  STATIC_RULES = {"claude": lambda: CLAUDE_RULES, "codex": codex_rules,
2474
- "omp": lambda: os.path.join(OMP_AGENT, "RULES.md")}
3060
+ "omp": lambda: os.path.join(OMP_AGENT, "RULES.md"),
3061
+ "qwen": lambda: os.path.join(QWEN, "QWEN.md"),
3062
+ "droid": lambda: os.path.join(DROID, "AGENTS.md"),
3063
+ "commandcode": lambda: os.path.join(COMMANDCODE, "AGENTS.md"),
3064
+ "copilot": lambda: os.path.join(COPILOT, "copilot-instructions.md"),
3065
+ "gemini": lambda: os.path.join(GEMINI, "GEMINI.md"),
3066
+ "antigravity": lambda: os.path.join(os.path.dirname(ANTIGRAVITY), "GEMINI.md"),
3067
+ "goose": lambda: os.path.join(GOOSE, ".goosehints"),
3068
+ "amp": lambda: os.path.join(AMP, "AGENTS.md")}
3069
+ # Gemini CLI and Antigravity both load ~/.gemini/GEMINI.md, so they share ONE
3070
+ # managed block: an uninstall strips it only while no other host of this tuple
3071
+ # that reads the same file stays armed (`gemini_md_kept`; GEMINI_CLI_HOME moves
3072
+ # Gemini's copy away), and each host's uninstall asks the same question, so the
3073
+ # last one out takes the block.
3074
+ GEMINI_MD_HOSTS = ("gemini", "antigravity")
3075
+ # the hosts the running uninstall takes out (`uninstall` sets it)
3076
+ UNINSTALLING = ()
3077
+
3078
+
3079
+ def gemini_md_kept(host):
3080
+ """True while another GEMINI_MD_HOSTS host this uninstall does not touch is
3081
+ armed and reads `host`'s GEMINI.md."""
3082
+ armed = tp.config().get("hosts") or []
3083
+ path = STATIC_RULES[host]()
3084
+ return any(h in armed and h not in UNINSTALLING and STATIC_RULES[h]() == path
3085
+ for h in GEMINI_MD_HOSTS)
2475
3086
 
2476
3087
 
2477
3088
  def rules_block(host):
@@ -2586,7 +3197,11 @@ def install_omp():
2586
3197
 
2587
3198
  INSTALLERS = {"claude": install_claude, "codex": install_codex,
2588
3199
  "opencode": install_opencode, "cursor": install_cursor,
2589
- "dsh": install_dsh, "omp": install_omp}
3200
+ "dsh": install_dsh, "omp": install_omp, "qwen": install_qwen,
3201
+ "droid": install_droid, "commandcode": install_commandcode,
3202
+ "copilot": install_copilot, "gemini": install_gemini,
3203
+ "antigravity": install_antigravity, "goose": install_goose,
3204
+ "amp": install_amp}
2590
3205
 
2591
3206
 
2592
3207
  def install_openresearch(hosts):
@@ -3466,6 +4081,67 @@ def verify_uninstall(hosts, full):
3466
4081
  claim("omp skills unlinked",
3467
4082
  [os.path.join(OMP_AGENT, "skills", s) for s in SKILLS
3468
4083
  if os.path.lexists(os.path.join(OMP_AGENT, "skills", s))])
4084
+ if "qwen" in hosts:
4085
+ clean("qwen settings.json unwired", [os.path.join(QWEN, "settings.json")])
4086
+ clean("qwen global QWEN.md block gone", [os.path.join(QWEN, "QWEN.md")],
4087
+ needle="<!-- tezgah:start")
4088
+ claim("qwen skills unlinked",
4089
+ [os.path.join(QWEN, "skills", s) for s in SKILLS
4090
+ if os.path.lexists(os.path.join(QWEN, "skills", s))])
4091
+ if "droid" in hosts:
4092
+ clean("droid hooks.json unwired", [os.path.join(DROID, "hooks.json")])
4093
+ clean("droid settings.json statusLine gone", [os.path.join(DROID, "settings.json")],
4094
+ needle="tezgah-statusline")
4095
+ clean("droid global AGENTS.md block gone", [os.path.join(DROID, "AGENTS.md")],
4096
+ needle="<!-- tezgah:start")
4097
+ claim("droid skills unlinked",
4098
+ [os.path.join(DROID, "skills", s) for s in SKILLS
4099
+ if os.path.lexists(os.path.join(DROID, "skills", s))])
4100
+ if "commandcode" in hosts:
4101
+ clean("commandcode settings.json unwired",
4102
+ [os.path.join(COMMANDCODE, "settings.json")])
4103
+ clean("commandcode global AGENTS.md block gone",
4104
+ [os.path.join(COMMANDCODE, "AGENTS.md")], needle="<!-- tezgah:start")
4105
+ claim("commandcode skills unlinked",
4106
+ [os.path.join(COMMANDCODE, "skills", s) for s in SKILLS
4107
+ if os.path.lexists(os.path.join(COMMANDCODE, "skills", s))])
4108
+ if "copilot" in hosts:
4109
+ claim("copilot hooks/tezgah.json gone",
4110
+ [os.path.join(COPILOT, "hooks", "tezgah.json")])
4111
+ clean("copilot settings.json statusLine gone",
4112
+ [os.path.join(COPILOT, "settings.json")], needle="tezgah-statusline")
4113
+ clean("copilot copilot-instructions.md block gone",
4114
+ [os.path.join(COPILOT, "copilot-instructions.md")], needle="<!-- tezgah:start")
4115
+ claim("copilot skills unlinked",
4116
+ [os.path.join(COPILOT, "skills", s) for s in SKILLS
4117
+ if os.path.lexists(os.path.join(COPILOT, "skills", s))])
4118
+ if "gemini" in hosts:
4119
+ claim("gemini extension dir gone", [GEMINI_EXT])
4120
+ if not gemini_md_kept("gemini"):
4121
+ clean("gemini GEMINI.md block gone", [os.path.join(GEMINI, "GEMINI.md")],
4122
+ needle="<!-- tezgah:start")
4123
+ if "antigravity" in hosts:
4124
+ clean("antigravity config/hooks.json unwired", [AGY_HOOKS],
4125
+ needle="tezgah-antigravity-hook")
4126
+ clean("antigravity settings.json statusLine gone",
4127
+ [os.path.join(ANTIGRAVITY, "settings.json")], needle="tezgah-statusline")
4128
+ claim("antigravity skills unlinked",
4129
+ [os.path.join(ANTIGRAVITY, "skills", s) for s in SKILLS
4130
+ if os.path.lexists(os.path.join(ANTIGRAVITY, "skills", s))])
4131
+ if not gemini_md_kept("antigravity"):
4132
+ clean("antigravity GEMINI.md block gone", [STATIC_RULES["antigravity"]()],
4133
+ needle="<!-- tezgah:start")
4134
+ if "goose" in hosts:
4135
+ claim("goose plugin dir gone", [GOOSE_PLUGIN])
4136
+ clean("goose .goosehints block gone", [STATIC_RULES["goose"]()],
4137
+ needle="<!-- tezgah:start")
4138
+ if "amp" in hosts:
4139
+ claim("amp plugin gone", [AMP_PLUGIN] if amp_plugin_ours() else [])
4140
+ claim("amp skills unlinked",
4141
+ [os.path.join(AMP, "skills", s) for s in SKILLS
4142
+ if os.path.lexists(os.path.join(AMP, "skills", s))])
4143
+ clean("amp AGENTS.md block gone", [STATIC_RULES["amp"]()],
4144
+ needle="<!-- tezgah:start")
3469
4145
  if "claude" in hosts:
3470
4146
  clean("claude settings.json unwired",
3471
4147
  [os.path.join(CLAUDE, "settings.json")])
@@ -3681,6 +4357,213 @@ def uninstall_cursor():
3681
4357
  print("%s removed tezgah statusLine from %s" % (OK, cc))
3682
4358
 
3683
4359
 
4360
+ def uninstall_qwen():
4361
+ print("\nqwen:")
4362
+ for s in SKILLS:
4363
+ unlink_tezgah(os.path.join(QWEN, "skills", s))
4364
+ path = os.path.join(QWEN, "settings.json")
4365
+ drop_managed_hook_entries(path)
4366
+ cfg = read_json(path, None)
4367
+ ui = cfg.get("ui") if isinstance(cfg, dict) else None
4368
+ if isinstance(ui, dict) and "tezgah-statusline" in json.dumps(ui.get("statusLine") or {}):
4369
+ ui.pop("statusLine")
4370
+ if not ui:
4371
+ cfg.pop("ui")
4372
+ write_json(path, cfg)
4373
+ print("%s removed tezgah statusLine from %s" % (OK, path))
4374
+ drop_if_empty(path)
4375
+ strip_managed_md(os.path.join(QWEN, "QWEN.md"))
4376
+
4377
+
4378
+ def drop_if_empty(path):
4379
+ """Remove a host settings file uninstall emptied: tezgah created it to hold
4380
+ its hooks or status line, and a `{}` the user never had is left over."""
4381
+ if read_json(path, None) == {}:
4382
+ backup(path)
4383
+ os.remove(path)
4384
+ print("%s removed %s (empty once tezgah's keys left)" % (OK, path))
4385
+
4386
+
4387
+ def uninstall_droid():
4388
+ print("\ndroid:")
4389
+ for s in SKILLS:
4390
+ unlink_tezgah(os.path.join(DROID, "skills", s))
4391
+ path = os.path.join(DROID, "hooks.json")
4392
+ data = read_json(path, None)
4393
+ if isinstance(data, dict):
4394
+ kept = {}
4395
+ for ev, arr in data.items():
4396
+ if isinstance(arr, list):
4397
+ arr = [e for e in arr if "tezgah" not in json.dumps(e)]
4398
+ if not arr:
4399
+ continue
4400
+ kept[ev] = arr
4401
+ if kept == data:
4402
+ print("%s no tezgah hook entries in %s" % (OK, path))
4403
+ elif kept:
4404
+ write_json(path, kept)
4405
+ print("%s removed tezgah hook entries from %s" % (OK, path))
4406
+ else:
4407
+ # an empty hooks.json would still hide the `hooks` key of settings.json
4408
+ backup(path)
4409
+ os.remove(path)
4410
+ print("%s removed %s (it held only tezgah's hooks)" % (OK, path))
4411
+ spath = os.path.join(DROID, "settings.json")
4412
+ cfg = read_json(spath, None)
4413
+ if isinstance(cfg, dict) and "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
4414
+ cfg.pop("statusLine")
4415
+ write_json(spath, cfg)
4416
+ print("%s removed tezgah statusLine from %s" % (OK, spath))
4417
+ elif cfg is None and os.path.exists(spath):
4418
+ # JSON with comments: take out the one line install_droid put in, keep
4419
+ # the comments; `includeCoAuthoredByDroid` stays false (the
4420
+ # no-attribution rule is the user's, not tezgah's to undo)
4421
+ with open(spath, encoding="utf-8") as fh:
4422
+ text = fh.read()
4423
+ old = (jsonc_loads(text) or {}).get("statusLine")
4424
+ new = jsonc_drop(text, "statusLine", old) if "tezgah-statusline" in json.dumps(old or "") else None
4425
+ if new is not None:
4426
+ write_text(spath, new)
4427
+ print("%s removed tezgah statusLine from %s" % (OK, spath))
4428
+ drop_if_empty(spath)
4429
+ strip_managed_md(os.path.join(DROID, "AGENTS.md"))
4430
+
4431
+
4432
+ def uninstall_commandcode():
4433
+ print("\ncommandcode:")
4434
+ for s in SKILLS:
4435
+ unlink_tezgah(os.path.join(COMMANDCODE, "skills", s))
4436
+ path = os.path.join(COMMANDCODE, "settings.json")
4437
+ drop_managed_hook_entries(path)
4438
+ cfg = read_json(path, None)
4439
+ attr = cfg.get("attribution") if isinstance(cfg, dict) else None
4440
+ # the value install wrote, beside the user's other attribution keys or
4441
+ # alone; as with Claude's, a user's own identical value cannot be told
4442
+ # apart from it, and dropping it only restores Command Code's default
4443
+ if isinstance(attr, dict) and attr.get("commit") == "":
4444
+ attr.pop("commit")
4445
+ if not attr:
4446
+ cfg.pop("attribution")
4447
+ write_json(path, cfg)
4448
+ print("%s removed tezgah's attribution.commit from %s" % (OK, path))
4449
+ drop_if_empty(path)
4450
+ strip_managed_md(os.path.join(COMMANDCODE, "AGENTS.md"))
4451
+
4452
+
4453
+ def uninstall_copilot():
4454
+ print("\ncopilot:")
4455
+ for s in SKILLS:
4456
+ unlink_tezgah(os.path.join(COPILOT, "skills", s))
4457
+ path = os.path.join(COPILOT, "hooks", "tezgah.json")
4458
+ if os.path.exists(path):
4459
+ backup(path)
4460
+ os.remove(path)
4461
+ print("%s removed %s" % (OK, path))
4462
+ spath = os.path.join(COPILOT, "settings.json")
4463
+ cfg = read_json(spath, None)
4464
+ if isinstance(cfg, dict):
4465
+ if "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
4466
+ cfg.pop("statusLine")
4467
+ # the value install wrote; a user's own false cannot be told apart from it
4468
+ if cfg.get("includeCoAuthoredBy") is False:
4469
+ cfg.pop("includeCoAuthoredBy")
4470
+ if write_json(spath, cfg):
4471
+ print("%s removed tezgah's statusLine and includeCoAuthoredBy from %s"
4472
+ % (OK, spath))
4473
+ drop_if_empty(spath)
4474
+ strip_managed_md(os.path.join(COPILOT, "copilot-instructions.md"))
4475
+
4476
+
4477
+ def uninstall_gemini():
4478
+ """Delete tezgah's extension dir (its skills are links, unlinked, never
4479
+ followed) and the GEMINI.md block unless Antigravity still reads it."""
4480
+ print("\ngemini:")
4481
+ if os.path.lexists(GEMINI_EXT):
4482
+ for name in ("gemini-extension.json", os.path.join("hooks", "hooks.json")):
4483
+ backup(os.path.join(GEMINI_EXT, name))
4484
+ shutil.rmtree(GEMINI_EXT)
4485
+ print("%s removed %s" % (OK, GEMINI_EXT))
4486
+ if gemini_md_kept("gemini"):
4487
+ print("%s %s keeps the tezgah block: another armed host reads it"
4488
+ % (OK, os.path.join(GEMINI, "GEMINI.md")))
4489
+ else:
4490
+ strip_managed_md(os.path.join(GEMINI, "GEMINI.md"))
4491
+
4492
+
4493
+ def uninstall_antigravity():
4494
+ """Take the `tezgah` name out of ~/.gemini/config/hooks.json (the user's
4495
+ other names stay), the statusLine, the skill links, and the GEMINI.md block
4496
+ unless Gemini CLI still reads it."""
4497
+ print("\nantigravity:")
4498
+ for s in SKILLS:
4499
+ unlink_tezgah(os.path.join(ANTIGRAVITY, "skills", s))
4500
+ data = read_json(AGY_HOOKS, None)
4501
+ if isinstance(data, dict) and "tezgah" in data:
4502
+ data.pop("tezgah")
4503
+ write_json(AGY_HOOKS, data)
4504
+ print("%s removed tezgah's hooks from %s" % (OK, AGY_HOOKS))
4505
+ drop_if_empty(AGY_HOOKS)
4506
+ spath = os.path.join(ANTIGRAVITY, "settings.json")
4507
+ cfg = read_json(spath, None)
4508
+ if isinstance(cfg, dict) and "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
4509
+ cfg.pop("statusLine")
4510
+ write_json(spath, cfg)
4511
+ print("%s removed tezgah's statusLine from %s" % (OK, spath))
4512
+ drop_if_empty(spath)
4513
+ rules = STATIC_RULES["antigravity"]()
4514
+ if gemini_md_kept("antigravity"):
4515
+ print("%s %s keeps the tezgah block: another armed host reads it" % (OK, rules))
4516
+ else:
4517
+ strip_managed_md(rules)
4518
+
4519
+
4520
+ def uninstall_goose():
4521
+ """Delete tezgah's plugin dir (its skills are links, unlinked, never
4522
+ followed) and the .goosehints block; the rest of ~/.agents is the user's."""
4523
+ print("\ngoose:")
4524
+ if os.path.lexists(GOOSE_PLUGIN):
4525
+ for name in ("plugin.json", os.path.join("hooks", "hooks.json")):
4526
+ backup(os.path.join(GOOSE_PLUGIN, name))
4527
+ shutil.rmtree(GOOSE_PLUGIN)
4528
+ print("%s removed %s" % (OK, GOOSE_PLUGIN))
4529
+ strip_managed_md(STATIC_RULES["goose"]())
4530
+
4531
+
4532
+ def amp_plugin_ours():
4533
+ """True when the file at AMP_PLUGIN is tezgah's: a fresh render, or one an
4534
+ older tree rendered (it starts with AMP_HEADER). A user's own plugin of
4535
+ that name is never removed."""
4536
+ try:
4537
+ with open(AMP_PLUGIN, encoding="utf-8") as fh:
4538
+ text = fh.read()
4539
+ except OSError:
4540
+ return False
4541
+ return text == ta.amp_plugin(HERE) or text.startswith(ta.AMP_HEADER)
4542
+
4543
+
4544
+ def uninstall_amp():
4545
+ """Delete tezgah's plugin file (only when it is tezgah's), the skill links,
4546
+ the AGENTS.md block, and each commit-trailer key while it is still false."""
4547
+ print("\namp:")
4548
+ if amp_plugin_ours():
4549
+ backup(AMP_PLUGIN)
4550
+ os.remove(AMP_PLUGIN)
4551
+ print("%s removed %s" % (OK, AMP_PLUGIN))
4552
+ elif os.path.lexists(AMP_PLUGIN):
4553
+ print("%s %s is not tezgah's render: left in place" % (NO, AMP_PLUGIN))
4554
+ for s in SKILLS:
4555
+ unlink_tezgah(os.path.join(AMP, "skills", s))
4556
+ spath, settings = amp_settings()
4557
+ if isinstance(settings, dict) and any(settings.get(k) is False for k in AMP_ATTRIBUTION):
4558
+ for key in AMP_ATTRIBUTION:
4559
+ if settings.get(key) is False:
4560
+ settings.pop(key)
4561
+ write_json(spath, settings)
4562
+ print("%s removed tezgah's commit-trailer keys from %s" % (OK, spath))
4563
+ drop_if_empty(spath)
4564
+ strip_managed_md(STATIC_RULES["amp"]())
4565
+
4566
+
3684
4567
  def uninstall_dsh():
3685
4568
  print("\ndsh:")
3686
4569
  for s in SKILLS:
@@ -3733,7 +4616,11 @@ def uninstall_omp():
3733
4616
 
3734
4617
  UNINSTALLERS = {"claude": uninstall_claude, "codex": uninstall_codex,
3735
4618
  "opencode": uninstall_opencode, "cursor": uninstall_cursor,
3736
- "dsh": uninstall_dsh, "omp": uninstall_omp}
4619
+ "dsh": uninstall_dsh, "omp": uninstall_omp, "qwen": uninstall_qwen,
4620
+ "droid": uninstall_droid, "commandcode": uninstall_commandcode,
4621
+ "copilot": uninstall_copilot, "gemini": uninstall_gemini,
4622
+ "antigravity": uninstall_antigravity, "goose": uninstall_goose,
4623
+ "amp": uninstall_amp}
3737
4624
 
3738
4625
 
3739
4626
  def uninstall(hosts, dry_run=False):
@@ -3755,6 +4642,7 @@ def uninstall(hosts, dry_run=False):
3755
4642
  repository's `.tezgah/` research state. A partial run (some armed hosts
3756
4643
  stay) also keeps config.json and the install tree, because the hosts still
3757
4644
  armed read both at runtime."""
4645
+ global UNINSTALLING
3758
4646
  armed = tp.config().get("hosts") or []
3759
4647
  full = not armed or set(hosts) >= set(armed)
3760
4648
  if dry_run:
@@ -3786,6 +4674,7 @@ def uninstall(hosts, dry_run=False):
3786
4674
  print("%s gate stays armed for the hosts this run does not touch" % OK)
3787
4675
  print("uninstalling for: %s (%s run)\n"
3788
4676
  % (", ".join(hosts) or "none detected", "full" if full else "partial"))
4677
+ UNINSTALLING = tuple(hosts)
3789
4678
  uninstall_common()
3790
4679
  for h in hosts:
3791
4680
  UNINSTALLERS[h]()
@@ -4128,12 +5017,13 @@ def context_budget_report():
4128
5017
 
4129
5018
  LIVE_TIMEOUT = 20
4130
5019
  LIVE_DENY_COMMAND = "git commit --no-verify -m x"
4131
- # The JS half: loads the omp extension (`--experimental-strip-types`, the .ts as
4132
- # omp's bun would) or the opencode plugin (an ES module, as opencode imports
4133
- # it), registers its handlers against inert host objects and fires one handler:
4134
- # the tool-result one, or with a command the tool-call one, printing what it
4135
- # answered (omp) or what it threw (opencode) as JSON. argv: host, module path,
4136
- # root, session id, command ("" for the tool result).
5020
+ # The JS half: loads the omp extension or the Amp plugin
5021
+ # (`--experimental-strip-types`, the .ts as their bun would) or the opencode
5022
+ # plugin (an ES module, as opencode imports it), registers its handlers against
5023
+ # inert host objects and fires one handler: the tool-result one, or with a
5024
+ # command the tool-call one, printing what it answered (omp, Amp) or what it
5025
+ # threw (opencode) as JSON. argv: host, module path, root, session id, command
5026
+ # ("" for the tool result).
4137
5027
  LIVE_DRIVER = r"""
4138
5028
  const [host, mod, root, sid, cmd] = process.argv.slice(1);
4139
5029
  const noop = new Proxy(function () {}, { get: () => noop, apply: () => noop });
@@ -4154,6 +5044,25 @@ const noop = new Proxy(function () {}, { get: () => noop, apply: () => noop });
4154
5044
  }
4155
5045
  await handlers.tool_result({ toolName: "bash", input: { command: "true" },
4156
5046
  isError: false, content: "ok" }, ctx);
5047
+ } else if (host === "amp") {
5048
+ // Amp's registry, its two helpers the plugin reads, and a thread context;
5049
+ // no status item (`experimental` absent), so no status spawn either
5050
+ const handlers = {};
5051
+ m.default(new Proxy({ on: (n, fn) => { handlers[n] = fn; }, experimental: undefined,
5052
+ system: { workspaceRoot: root },
5053
+ helpers: { filePathFromURI: (u) => String(u),
5054
+ shellCommandFromToolCall: (e) => ({ command: e.input.cmd }) } },
5055
+ { get: (t, k) => (k in t ? t[k] : noop) }));
5056
+ const want = cmd ? "tool.call" : "tool.result";
5057
+ if (!handlers[want]) throw new Error("the plugin registered no " + want + " handler");
5058
+ const ctx = { thread: { id: sid, parentThreadID: async () => null }, ui: noop };
5059
+ const event = { tool: "Bash", input: { cmd: cmd || "true" }, toolUseID: "live",
5060
+ thread: { id: sid } };
5061
+ if (cmd) {
5062
+ process.stdout.write(JSON.stringify((await handlers[want](event, ctx)) || {}));
5063
+ return;
5064
+ }
5065
+ await handlers[want](Object.assign(event, { status: "done", output: "ok" }), ctx);
4157
5066
  } else {
4158
5067
  const hooks = await (m.Tezgah || m.default)({ directory: root, worktree: root,
4159
5068
  client: noop, $: noop });
@@ -4316,6 +5225,123 @@ def live_plan(host, deny=False):
4316
5225
  if registered is None:
4317
5226
  return plan + ("registration unread: the omp CLI did not answer",)
4318
5227
  return plan
5228
+ if host == "qwen":
5229
+ # the hook command Qwen runs for its shell tool, fed Qwen's own payload;
5230
+ # no qwen binary and no model call (tests/e2e_qwen.py drives the binary)
5231
+ path = os.path.join(QWEN, "settings.json")
5232
+ cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event),
5233
+ "run_shell_command")
5234
+ if not cmd:
5235
+ return ("miss", "no tezgah %s command for run_shell_command in %s"
5236
+ % (event, path))
5237
+ payload = {"hook_event_name": event, "tool_name": "run_shell_command",
5238
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
5239
+ if not deny:
5240
+ payload["tool_response"] = {"llmContent": "", "returnDisplay": ""}
5241
+ return ("shell", cmd, payload, {})
5242
+ if host == "droid":
5243
+ # the hook command Droid runs for Execute, fed Droid's own payload; no
5244
+ # droid binary and no model call (tests/e2e_droid.py drives the binary)
5245
+ path = os.path.join(DROID, "hooks.json")
5246
+ cmd = _hook_command(read_json(path, {}).get(event), "Execute")
5247
+ if not cmd:
5248
+ return ("miss", "no tezgah %s command for Execute in %s" % (event, path))
5249
+ payload = {"hook_event_name": event, "tool_name": "Execute",
5250
+ "permission_mode": "auto-low",
5251
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
5252
+ if not deny:
5253
+ payload["tool_response"] = "\n\n[Process exited with code 0]"
5254
+ return ("shell", cmd, payload, {})
5255
+ if host == "commandcode":
5256
+ # the hook command Command Code runs for its shell tool (the matcher sees
5257
+ # the display name SHELL, case-insensitively), fed its own payload; no
5258
+ # binary and no model call
5259
+ path = os.path.join(COMMANDCODE, "settings.json")
5260
+ cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event), "shell")
5261
+ if not cmd:
5262
+ return ("miss", "no tezgah %s command for shell in %s" % (event, path))
5263
+ payload = {"hook_event_name": event, "tool_name": "shell_command",
5264
+ "tool_display_name": "SHELL", "permission_mode": "default",
5265
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
5266
+ if not deny:
5267
+ payload["tool_response"] = ""
5268
+ return ("shell", cmd, payload, {})
5269
+ if host == "copilot":
5270
+ # the hook command Copilot runs for its shell tool (a PascalCase
5271
+ # PreToolUse sees the Claude name Bash), fed its own payload; no binary
5272
+ # and no model call
5273
+ path = os.path.join(COPILOT, "hooks", "tezgah.json")
5274
+ cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event), "Bash",
5275
+ own=True)
5276
+ if not cmd:
5277
+ return ("miss", "no tezgah %s command for Bash in %s" % (event, path))
5278
+ payload = {"hook_event_name": event, "tool_name": "Bash",
5279
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
5280
+ if not deny:
5281
+ payload["tool_result"] = {"result_type": "success",
5282
+ "text_result_for_llm": "<exited with exit code 0>"}
5283
+ return ("shell", cmd, payload, {})
5284
+ if host == "gemini":
5285
+ # the hook command Gemini runs for its shell tool (BeforeTool/AfterTool),
5286
+ # fed Gemini's own payload; no binary and no model call
5287
+ path = os.path.join(GEMINI_EXT, "hooks", "hooks.json")
5288
+ name = "BeforeTool" if deny else "AfterTool"
5289
+ cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(name),
5290
+ "run_shell_command", own=True)
5291
+ if not cmd:
5292
+ return ("miss", "no tezgah %s command for run_shell_command in %s"
5293
+ % (name, path))
5294
+ payload = {"hook_event_name": name, "tool_name": "run_shell_command",
5295
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
5296
+ if not deny:
5297
+ payload["tool_response"] = {"llmContent": "Output: (empty)",
5298
+ "returnDisplay": ""}
5299
+ return ("shell", cmd, payload, {})
5300
+ if host == "antigravity":
5301
+ # the hook command Antigravity runs for run_command, under the `tezgah`
5302
+ # name of ~/.gemini/config/hooks.json; it carries the event as argv[1],
5303
+ # since the camelCase payload names none. No binary and no model call
5304
+ cmd = _hook_command((read_json(AGY_HOOKS, {}).get("tezgah") or {}).get(event),
5305
+ "run_command", own=True)
5306
+ if not cmd:
5307
+ return ("miss", "no tezgah %s command for run_command in %s" % (event, AGY_HOOKS))
5308
+ payload = {"toolCall": {"name": "run_command", "args": {
5309
+ "CommandLine": LIVE_DENY_COMMAND if deny else "true", "WaitMsBeforeAsync": 5000}},
5310
+ "stepIdx": 0}
5311
+ if not deny:
5312
+ payload["error"] = ""
5313
+ return ("shell", cmd, payload, {})
5314
+ if host == "goose":
5315
+ # the hook command Goose runs for its `shell` tool, from tezgah's plugin
5316
+ # hooks.json, fed Goose's own payload (the event in `event`, the
5317
+ # directory as `working_dir`); no binary and no model call
5318
+ path = os.path.join(GOOSE_PLUGIN, "hooks", "hooks.json")
5319
+ cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event), "shell",
5320
+ own=True)
5321
+ if not cmd:
5322
+ return ("miss", "no tezgah %s command for shell in %s" % (event, path))
5323
+ return ("shell", cmd, {"event": event, "tool_name": "shell",
5324
+ "matcher_context": "shell", "tool_call_id": "tezgah-live",
5325
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny
5326
+ else "true"}}, {})
5327
+ if host == "amp":
5328
+ # the installed plugin itself, loaded by node through LIVE_DRIVER and fed
5329
+ # Amp's own event shape (`tool`, `input.cmd`, `thread.id`); the hook it
5330
+ # spawns is the one Amp would spawn
5331
+ try:
5332
+ with open(AMP_PLUGIN, encoding="utf-8") as fh:
5333
+ text = fh.read()
5334
+ except OSError:
5335
+ return ("miss", "no plugin at %s" % AMP_PLUGIN)
5336
+ if text != ta.amp_plugin(HERE):
5337
+ return ("miss", "%s differs from a fresh render of this tree's plugin "
5338
+ "- re-run `tezgah-setup --install --hosts amp`" % AMP_PLUGIN)
5339
+ node = _node_for_ts()
5340
+ if not node:
5341
+ return ("unverified", "no node 22.6+ to load %s" % AMP_PLUGIN)
5342
+ # always unverified: Amp's own Bun loader is not what ran it
5343
+ return ("node", [node, "--experimental-strip-types", "--no-warnings"], AMP_PLUGIN,
5344
+ "Amp's own plugin loader is not driven here")
4319
5345
  return ("unverified", "no live probe for this host")
4320
5346
 
4321
5347
 
@@ -4333,6 +5359,21 @@ def _denied(host, stdout):
4333
5359
  return got.get("block") is True
4334
5360
  if host == "opencode":
4335
5361
  return bool(got.get("threw"))
5362
+ if host == "copilot":
5363
+ # top level, not wrapped (hooks-reference "preToolUse decision control")
5364
+ return got.get("permissionDecision") == "deny"
5365
+ if host == "gemini":
5366
+ # top level (hooks reference "BeforeTool"; `block` is its alias)
5367
+ return got.get("decision") in ("deny", "block")
5368
+ if host == "antigravity":
5369
+ # top level; `decision` is required, so an allow answers too (docs/hooks)
5370
+ return got.get("decision") == "deny"
5371
+ if host == "goose":
5372
+ # top level, stdout starting with `{` (hooks "Blocking a Tool Call")
5373
+ return got.get("decision") == "block"
5374
+ if host == "amp":
5375
+ # ToolCallResult (plugin-api): the call is refused, the turn goes on
5376
+ return got.get("action") == "reject-and-continue"
4336
5377
  return (got.get("hookSpecificOutput") or {}).get("permissionDecision") == "deny"
4337
5378
 
4338
5379
 
@@ -4365,7 +5406,13 @@ def live_probe(host, deny=False):
4365
5406
  if plan[0] == "shell":
4366
5407
  _kind, cmd, payload, extra = plan
4367
5408
  env.update(extra)
4368
- payload = dict(payload, session_id=sid, conversation_id=sid, cwd=root)
5409
+ # Antigravity's envelope is camelCase, with no session_id or cwd;
5410
+ # Goose names the directory `working_dir`
5411
+ payload = (dict(payload, conversationId=sid, workspacePaths=[root])
5412
+ if host == "antigravity" else
5413
+ dict(payload, session_id=sid, working_dir=root)
5414
+ if host == "goose" else
5415
+ dict(payload, session_id=sid, conversation_id=sid, cwd=root))
4369
5416
  proc = subprocess.run(cmd, shell=True, input=json.dumps(payload),
4370
5417
  capture_output=True, text=True, env=env,
4371
5418
  cwd=root, timeout=LIVE_TIMEOUT)
@@ -4438,7 +5485,9 @@ ARMING_ROWS = ("hooks.json wired", "plugin wired (link or copy)",
4438
5485
  "carries the contract", "contract in instructions",
4439
5486
  "claude-code hooks bridge in cordis.patch.yml",
4440
5487
  "session, prompt, tool and stop hooks wired",
4441
- "hook registered in omp extensions", "plugin copy current")
5488
+ "hook registered in omp extensions", "plugin copy current",
5489
+ "settings.json hooks wired", "hooks/tezgah.json wired",
5490
+ "plugin tezgah.ts current")
4442
5491
 
4443
5492
 
4444
5493
  def arming_miss(label, good):
@@ -4704,14 +5753,17 @@ def claude_mcp_rows(copies):
4704
5753
  return rows
4705
5754
 
4706
5755
 
4707
- def _hooks_has(path, marker, events=None):
5756
+ def _hooks_has(path, marker, events=None, bare=False, key="hooks"):
4708
5757
  """True when `marker` is wired under every event in `events`, or under any
4709
- event when `events` is None.
5758
+ event when `events` is None. `bare`: the file is keyed by event at the top
5759
+ (Droid's hooks.json); `key`: the top-level name the events sit under
5760
+ (`hooks`, or Antigravity's `tezgah`).
4710
5761
 
4711
5762
  A row that one event can satisfy answers the wrong question: a manifest
4712
5763
  carrying tezgah's command under SessionStart and not under Stop reads as
4713
5764
  wired, and Stop is the integrity refusal."""
4714
- hooks = (read_json(path, {}) or {}).get("hooks") or {}
5765
+ data = read_json(path, {}) or {}
5766
+ hooks = data if bare else data.get(key) or {}
4715
5767
  if events is None:
4716
5768
  return any(marker in json.dumps(e) for arr in hooks.values() for e in arr)
4717
5769
  return all(any(marker in json.dumps(e) for e in (hooks.get(ev) or []))
@@ -4850,6 +5902,157 @@ def host_checks_cursor():
4850
5902
  ]
4851
5903
 
4852
5904
 
5905
+ def host_checks_qwen():
5906
+ path = os.path.join(QWEN, "settings.json")
5907
+ # a relocated home (QWEN_HOME) is named, as codex's is
5908
+ where = " in %s" % QWEN if QWEN != os.path.join(tp.HOME, ".qwen") else ""
5909
+ ui = read_json(path, {}).get("ui")
5910
+ return [
5911
+ ("settings.json hooks wired%s" % where,
5912
+ _hooks_has(path, "tezgah-qwen-hook", [ev for ev, _m, _t in QWEN_HOOK_EVENTS])),
5913
+ ("skills linked%s" % where, skills_linked(os.path.join(QWEN, "skills"))),
5914
+ ("QWEN.md carries the contract (matches a fresh render)", rules_current("qwen")),
5915
+ ("statusLine in settings.json ui",
5916
+ "tezgah-statusline" in json.dumps((ui if isinstance(ui, dict) else {})
5917
+ .get("statusLine") or {})),
5918
+ ("qwen CLI on PATH", bool(tp.which_user("qwen"))),
5919
+ ]
5920
+
5921
+
5922
+ def host_checks_droid():
5923
+ path = os.path.join(DROID, "hooks.json")
5924
+ why = droid_hooks_blocked()
5925
+ settings = read_jsonc(os.path.join(DROID, "settings.json")) or {}
5926
+ status = settings.get("statusLine")
5927
+ return [
5928
+ ("hooks.json wired" + (" (not written: %s)" % why if why else ""),
5929
+ _hooks_has(path, "tezgah-droid-hook", [ev for ev, _m, _t in DROID_HOOK_EVENTS],
5930
+ bare=True)),
5931
+ ("skills linked", skills_linked(os.path.join(DROID, "skills"))),
5932
+ ("AGENTS.md carries the contract (matches a fresh render)", rules_current("droid")),
5933
+ ("statusLine in settings.json", "tezgah-statusline" in json.dumps(status or {})),
5934
+ ("includeCoAuthoredByDroid off in settings.json",
5935
+ settings.get("includeCoAuthoredByDroid") is False),
5936
+ ("droid CLI on PATH", bool(tp.which_user("droid"))),
5937
+ ]
5938
+
5939
+
5940
+ def host_checks_commandcode():
5941
+ path = os.path.join(COMMANDCODE, "settings.json")
5942
+ attr = read_json(path, {}).get("attribution")
5943
+ return [
5944
+ ("settings.json hooks wired",
5945
+ _hooks_has(path, "tezgah-commandcode-hook",
5946
+ [ev for ev, _m, _t in COMMANDCODE_HOOK_EVENTS])),
5947
+ ("skills linked", skills_linked(os.path.join(COMMANDCODE, "skills"))),
5948
+ ("AGENTS.md carries the contract (matches a fresh render)",
5949
+ rules_current("commandcode")),
5950
+ ("attribution.commit off in settings.json",
5951
+ isinstance(attr, dict) and attr.get("commit") == ""),
5952
+ ("command-code CLI on PATH", bool(tp.which_user("command-code"))),
5953
+ ]
5954
+
5955
+
5956
+ def host_checks_copilot():
5957
+ # a relocated home (COPILOT_HOME) is named, as codex's is
5958
+ where = " in %s" % COPILOT if COPILOT != os.path.join(tp.HOME, ".copilot") else ""
5959
+ settings = read_jsonc(os.path.join(COPILOT, "settings.json")) or {}
5960
+ return [
5961
+ ("hooks/tezgah.json wired%s" % where,
5962
+ _hooks_has(os.path.join(COPILOT, "hooks", "tezgah.json"), "tezgah-copilot-hook",
5963
+ [ev for ev, _m, _t in COPILOT_HOOK_EVENTS])),
5964
+ ("skills linked%s" % where, skills_linked(os.path.join(COPILOT, "skills"))),
5965
+ ("copilot-instructions.md carries the contract (matches a fresh render)",
5966
+ rules_current("copilot")),
5967
+ ("statusLine in settings.json",
5968
+ "tezgah-statusline" in json.dumps(settings.get("statusLine") or {})),
5969
+ ("includeCoAuthoredBy off in settings.json",
5970
+ settings.get("includeCoAuthoredBy") is False),
5971
+ ("copilot CLI on PATH", bool(tp.which_user("copilot"))),
5972
+ ]
5973
+
5974
+
5975
+ def host_checks_gemini():
5976
+ # a relocated home (GEMINI_CLI_HOME) is named, as codex's is
5977
+ where = " in %s" % GEMINI if GEMINI != os.path.join(tp.HOME, ".gemini") else ""
5978
+ hooks_cfg = (read_jsonc(os.path.join(GEMINI, "settings.json")) or {}).get("hooksConfig")
5979
+ return [
5980
+ # the hooks load only beside a manifest naming the extension
5981
+ ("extension hooks/hooks.json wired%s" % where,
5982
+ _hooks_has(os.path.join(GEMINI_EXT, "hooks", "hooks.json"), "tezgah-gemini-hook",
5983
+ [ev for ev, _m, _t in GEMINI_HOOK_EVENTS])
5984
+ and read_json(os.path.join(GEMINI_EXT, "gemini-extension.json"), {})
5985
+ .get("name") == "tezgah"),
5986
+ ("skills linked%s" % where, skills_linked(os.path.join(GEMINI_EXT, "skills"))),
5987
+ ("GEMINI.md carries the contract (matches a fresh render)", rules_current("gemini")),
5988
+ # `hooksConfig.enabled: false` runs no hook at all (configuration)
5989
+ ("hooks left on in settings.json (hooksConfig.enabled)",
5990
+ not (isinstance(hooks_cfg, dict) and hooks_cfg.get("enabled") is False)),
5991
+ ("gemini CLI on PATH", bool(tp.which_user("gemini"))),
5992
+ ]
5993
+
5994
+
5995
+ def host_checks_antigravity():
5996
+ settings = read_json(os.path.join(ANTIGRAVITY, "settings.json"), {})
5997
+ mine = read_json(AGY_HOOKS, {}).get("tezgah")
5998
+ return [
5999
+ ("config/hooks.json wired (the `tezgah` name)",
6000
+ _hooks_has(AGY_HOOKS, "tezgah-antigravity-hook",
6001
+ [ev for ev, _m, _t in ANTIGRAVITY_HOOK_EVENTS], key="tezgah")),
6002
+ # `enabled: false` on a hook name loads none of its events (docs/hooks)
6003
+ ("tezgah hook set left enabled in config/hooks.json",
6004
+ not (isinstance(mine, dict) and mine.get("enabled") is False)),
6005
+ ("skills linked", skills_linked(os.path.join(ANTIGRAVITY, "skills"))),
6006
+ ("GEMINI.md carries the contract (matches a fresh render)",
6007
+ rules_current("antigravity")),
6008
+ ("statusLine in antigravity-cli/settings.json",
6009
+ "tezgah-statusline" in json.dumps(settings.get("statusLine") or {})),
6010
+ ("agy CLI on PATH", bool(tp.which_user("agy"))),
6011
+ ]
6012
+
6013
+
6014
+ def host_checks_goose():
6015
+ disabled = read_json(os.path.join(GOOSE, "settings.json"), {}).get("disabledPlugins")
6016
+ return [
6017
+ # the hooks load only from a plugin dir under ~/.agents/plugins
6018
+ ("plugin hooks/hooks.json wired (~/.agents/plugins/tezgah)",
6019
+ _hooks_has(os.path.join(GOOSE_PLUGIN, "hooks", "hooks.json"), "tezgah-goose-hook",
6020
+ [ev for ev, _m, _t in GOOSE_HOOK_EVENTS])
6021
+ and read_json(os.path.join(GOOSE_PLUGIN, "plugin.json"), {}).get("name") == "tezgah"),
6022
+ # a plugin named in `disabledPlugins` is skipped at discovery (plugins guide)
6023
+ ("tezgah plugin not in disabledPlugins (settings.json)",
6024
+ not (isinstance(disabled, list) and "tezgah" in disabled)),
6025
+ ("skills linked", skills_linked(os.path.join(GOOSE_PLUGIN, "skills"))),
6026
+ (".goosehints carries the contract (matches a fresh render)", rules_current("goose")),
6027
+ ("goose CLI on PATH", bool(tp.which_user("goose"))),
6028
+ ]
6029
+
6030
+
6031
+ def host_checks_amp():
6032
+ try:
6033
+ with open(AMP_PLUGIN, encoding="utf-8") as fh:
6034
+ plugin = fh.read()
6035
+ except OSError:
6036
+ plugin = ""
6037
+ # either user settings file; the comment-tolerant reader for both
6038
+ settings = (read_jsonc(os.path.join(AMP, "settings.json")) or {}
6039
+ if not os.path.exists(os.path.join(AMP, "settings.jsonc"))
6040
+ else read_jsonc(os.path.join(AMP, "settings.jsonc")) or {})
6041
+ return [
6042
+ # byte for byte, as omp's bridge row: a hand edit or an older tree's
6043
+ # render is not the wiring this tree arms
6044
+ ("plugin tezgah.ts current (matches a fresh render): %s" % AMP_PLUGIN,
6045
+ bool(plugin) and plugin == ta.amp_plugin(HERE)),
6046
+ ("skills linked", skills_linked(os.path.join(AMP, "skills"))),
6047
+ ("AGENTS.md carries the contract (matches a fresh render)", rules_current("amp")),
6048
+ ("%s off in settings.json" % AMP_ATTRIBUTION[0],
6049
+ settings.get(AMP_ATTRIBUTION[0]) is False),
6050
+ ("%s off in settings.json" % AMP_ATTRIBUTION[1],
6051
+ settings.get(AMP_ATTRIBUTION[1]) is False),
6052
+ ("amp CLI on PATH", bool(tp.which_user("amp"))),
6053
+ ]
6054
+
6055
+
4853
6056
  def host_checks_dsh():
4854
6057
  try:
4855
6058
  with open(DSH_PATCH, encoding="utf-8") as fh:
@@ -4968,7 +6171,11 @@ def host_checks_omp():
4968
6171
 
4969
6172
  HOST_CHECKS = {"claude": host_checks_claude, "codex": host_checks_codex,
4970
6173
  "opencode": host_checks_opencode, "cursor": host_checks_cursor,
4971
- "dsh": host_checks_dsh, "omp": host_checks_omp}
6174
+ "dsh": host_checks_dsh, "omp": host_checks_omp, "qwen": host_checks_qwen,
6175
+ "droid": host_checks_droid, "commandcode": host_checks_commandcode,
6176
+ "copilot": host_checks_copilot, "gemini": host_checks_gemini,
6177
+ "antigravity": host_checks_antigravity, "goose": host_checks_goose,
6178
+ "amp": host_checks_amp}
4972
6179
 
4973
6180
 
4974
6181
  def versions_agree():