@r1z4x/tezgah 1.11.0 → 1.14.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 (109) hide show
  1. package/CHANGELOG.md +507 -1
  2. package/MANIFEST +40 -0
  3. package/NOTICE +152 -0
  4. package/README.de.md +41 -20
  5. package/README.es.md +39 -19
  6. package/README.fr.md +41 -20
  7. package/README.md +18 -10
  8. package/README.tr.md +39 -21
  9. package/bin/codegen +9 -2
  10. package/bin/consult +6 -0
  11. package/bin/tezgah-adhd +3 -0
  12. package/bin/tezgah-agents +3 -0
  13. package/bin/tezgah-capture +3 -0
  14. package/bin/tezgah-design +329 -54
  15. package/bin/tezgah-docs +158 -42
  16. package/bin/tezgah-dsh +1 -1
  17. package/bin/tezgah-gate +7 -2
  18. package/bin/tezgah-import-ai-research +16 -4
  19. package/bin/tezgah-index +3 -0
  20. package/bin/tezgah-lessons +13 -4
  21. package/bin/tezgah-mcp +4 -0
  22. package/bin/tezgah-migrate +3 -0
  23. package/bin/tezgah-ocr-review +2 -1
  24. package/bin/tezgah-optimize +4 -1
  25. package/bin/tezgah-pony +3 -0
  26. package/bin/tezgah-research +51 -10
  27. package/bin/tezgah-rollback +3 -0
  28. package/bin/tezgah-route +1 -1
  29. package/bin/tezgah-setup +1741 -95
  30. package/bin/tezgah-status +11 -1
  31. package/bin/tezgah-task +58 -17
  32. package/bin/tezgah.js +10 -3
  33. package/commands/adhd.md +1 -1
  34. package/docs/README.md +19 -8
  35. package/docs/architecture.md +2 -2
  36. package/docs/citations-baseline.json +11 -17
  37. package/docs/clarity-baseline.json +12 -6
  38. package/docs/contract.md +83 -34
  39. package/docs/environment.md +59 -24
  40. package/docs/evidence.md +128 -25
  41. package/docs/gate.md +77 -23
  42. package/docs/glossary.md +1 -1
  43. package/docs/hosts.md +343 -48
  44. package/docs/layers.md +2 -2
  45. package/docs/models.md +6 -0
  46. package/docs/operations.md +83 -29
  47. package/docs/research.md +37 -11
  48. package/docs/skills.md +31 -8
  49. package/docs/status-line.md +39 -23
  50. package/docs/testing.md +50 -17
  51. package/docs/vision.md +6 -2
  52. package/hooks/hooks.json +1 -1
  53. package/hooks/lang/en.json +16 -2
  54. package/hooks/lang/tr.json +14 -2
  55. package/hooks/projects-posttooluse.py +11 -3
  56. package/hooks/projects-stop.py +3 -2
  57. package/hooks/tezgah_agents.py +27 -3
  58. package/hooks/tezgah_attest.py +79 -10
  59. package/hooks/tezgah_context.py +61 -31
  60. package/hooks/tezgah_gate.py +480 -126
  61. package/hooks/tezgah_integrity.py +577 -133
  62. package/hooks/tezgah_lessons.py +5 -2
  63. package/hooks/tezgah_paths.py +60 -8
  64. package/hooks/tezgah_policy.py +48 -18
  65. package/hooks/tezgah_replay.py +8 -2
  66. package/hooks/tezgah_research.py +167 -24
  67. package/hooks/tezgah_skill_pick.py +1 -1
  68. package/hooks/tezgah_snapshot.py +6 -3
  69. package/hooks/tezgah_stack.py +236 -1
  70. package/hooks/tezgah_stopspec.py +2 -1
  71. package/hooks/tezgah_task.py +2 -1
  72. package/hosts/amp/hook.py +217 -0
  73. package/hosts/amp/tezgah-plugin.ts.in +396 -0
  74. package/hosts/antigravity/hook.py +255 -0
  75. package/hosts/codex/hook.py +3 -2
  76. package/hosts/commandcode/hook.py +239 -0
  77. package/hosts/copilot/hook.py +284 -0
  78. package/hosts/cursor/hook.py +4 -2
  79. package/hosts/droid/hook.py +259 -0
  80. package/hosts/dsh/hooks.json +1 -1
  81. package/hosts/gemini/hook.py +213 -0
  82. package/hosts/goose/hook.py +145 -0
  83. package/hosts/omp/hook.py +5 -2
  84. package/hosts/omp/tezgah-hook.ts.in +157 -42
  85. package/hosts/opencode/plugins/tezgah.js +209 -63
  86. package/hosts/qwen/hook.py +206 -0
  87. package/output-styles/tezgah.md +11 -6
  88. package/package.json +6 -3
  89. package/packaging/build.sh +4 -4
  90. package/packaging/install.sh +11 -0
  91. package/packaging/upgrade.sh +3 -0
  92. package/skills/ai-research/LICENSE +21 -0
  93. package/skills/design-contract/SKILL.md +27 -6
  94. package/skills/design-library/LICENSE +21 -0
  95. package/skills/harness/SKILL.md +2 -0
  96. package/skills/i-have-adhd/SKILL.md +10 -1
  97. package/skills/plan-add/SKILL.md +5 -1
  98. package/skills/plan-add/render_table.py +7 -0
  99. package/skills/pm-frameworks/LICENSE +21 -0
  100. package/skills/research/SKILL.md +22 -9
  101. package/skills/rl-env/LICENSE +201 -0
  102. package/skills/tezgah-contract/SKILL.md +21 -8
  103. package/statusline.py +20 -9
  104. package/third_party/open-code-review/LICENSE +201 -0
  105. package/third_party/open-code-review/README.md +15 -0
  106. package/third_party/open-code-review/prompts/main_task_system.md +26 -0
  107. package/third_party/open-code-review/prompts/review_filter_task_system.md +13 -0
  108. package/workflows/graph-map.js +14 -5
  109. 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
@@ -239,6 +262,11 @@ SKILL_ROUTER = os.path.join(tp.CONFIG_DIR, "opencode-skills.md")
239
262
  # Where `link()` records the entries it had to materialise instead of
240
263
  # symlinking; see materialised_paths() for why the fact has to be written down.
241
264
  MATERIALISED = os.path.join(tp.CONFIG_DIR, "materialised")
265
+ # What an install found in each host file it edited, so an uninstall puts back
266
+ # exactly that: {path: {"created": bool, "<key>": prior value | ABSENT}}, a key
267
+ # being a "/"-joined path into the file's JSON object. See remember_prior().
268
+ PRIOR = os.path.join(tp.CONFIG_DIR, "install-prior.json")
269
+ ABSENT = "absent"
242
270
  # Every kill switch the code reads (`off("...")` under hooks/ and bin/;
243
271
  # `bin/tezgah-docs --citations` checks the two agree). The canonical
244
272
  # copies die with ~/.config/tezgah; the pre-multi-host setup wrote them to
@@ -257,7 +285,8 @@ OFF_SWITCH_NAMES = ("adhd-off", "agents-off", "consult-off", "design-off", "docs
257
285
  # moved aside - the only copy of them on disk.
258
286
  GENERATED_STATE = frozenset((
259
287
  "bin", "config.json", "contract.sha256", "contract.sha256.lock",
260
- "contract.sha256.tmp", "install.log", "materialised",
288
+ "contract.sha256.tmp", "install-prior.json", "install-prior.json.tmp",
289
+ "install.log", "materialised",
261
290
  "opencode-contract.md", "opencode-skills.md", "opencode-skills.full.md",
262
291
  "ponytail.level", "state"))
263
292
  # The predecessor harness's marker in an AGENTS.md under a root: the block tells
@@ -324,6 +353,73 @@ CURSOR_HOOK_EVENTS = {
324
353
  "afterFileEdit": {"timeout": 10},
325
354
  "afterAgentResponse": {"timeout": 5},
326
355
  }
356
+ # Qwen Code 0.21.13: a matcher is a regex over the runtime tool id, and a
357
+ # command hook's `timeout` is in MILLISECONDS (60000 by default) - the seconds
358
+ # value the other manifests carry would time every hook out. Stop and
359
+ # SubagentStop run the integrity rule, which may ask a judge, so they get more.
360
+ # Qwen takes MCP tools as `mcp__<server>__<tool>`.
361
+ QWEN_PRETOOL_MATCHER = ("^(run_shell_command|monitor|write_file|edit|replace|notebook_edit|"
362
+ "agent|grep_search|mcp__.*)$")
363
+ QWEN_HOOK_EVENTS = (("SessionStart", None, 5000), ("UserPromptSubmit", None, 5000),
364
+ ("PreToolUse", QWEN_PRETOOL_MATCHER, 5000),
365
+ ("PostToolUse", None, 5000), ("PostToolUseFailure", None, 5000),
366
+ ("SubagentStart", None, 5000), ("SubagentStop", None, 30000),
367
+ ("Stop", None, 30000))
368
+ # Factory Droid 0.251.0 (docs.factory.com harness/hooks): a matcher is a
369
+ # case-sensitive regex over the tool id, and `timeout` is in seconds. Droid has
370
+ # no SubagentStart and no failure event. MCP tools are `mcp__<server>__<tool>`.
371
+ DROID_PRETOOL_MATCHER = "^(Execute|Create|Edit|ApplyPatch|Task|Grep|mcp__.*)$"
372
+ DROID_HOOK_EVENTS = (("SessionStart", None, 5), ("UserPromptSubmit", None, 5),
373
+ ("PreToolUse", DROID_PRETOOL_MATCHER, 5), ("PostToolUse", None, 5),
374
+ ("SubagentStop", None, 30), ("Stop", None, 30))
375
+ # Command Code (commandcode.ai/docs/hooks): four events, `timeout` in seconds, and
376
+ # a matcher tested against the tool's display name (SHELL, READ, WRITE, EDIT) -
377
+ # its settings hooks see no other tool, so MCP, subagent and web calls are not
378
+ # gated there. A matcher on SessionStart or Stop stops the hook from firing.
379
+ COMMANDCODE_PRETOOL_MATCHER = "shell|write|edit"
380
+ COMMANDCODE_HOOK_EVENTS = (("SessionStart", None, 5),
381
+ ("PreToolUse", COMMANDCODE_PRETOOL_MATCHER, 5),
382
+ ("PostToolUse", None, 5), ("Stop", None, 30))
383
+ # GitHub Copilot CLI (docs.github.com hooks-reference): tezgah's own file in the
384
+ # user hooks dir, flat entries with `timeoutSec` in seconds. The PascalCase names
385
+ # get snake_case payloads and Claude tool names; `subagentStart` has no PascalCase
386
+ # form, so its camelCase payload names no event and the command passes it. A
387
+ # PascalCase PreToolUse matcher is anchored and tested against the Claude tool
388
+ # name; an MCP tool is `<server>-<tool>`. A timed-out PreToolUse lets the call
389
+ # through, hence 10 s there.
390
+ COPILOT_PRETOOL_MATCHER = "^(?:Bash|Write|Edit|Agent|Task|Grep|.+-.+)$"
391
+ COPILOT_HOOK_EVENTS = (("SessionStart", None, 5), ("UserPromptSubmit", None, 5),
392
+ ("PreToolUse", COPILOT_PRETOOL_MATCHER, 10), ("PostToolUse", None, 5),
393
+ ("PostToolUseFailure", None, 5), ("subagentStart", None, 5),
394
+ ("SubagentStop", None, 30), ("Stop", None, 30))
395
+ # Gemini CLI (geminicli hooks reference): `timeout` in MILLISECONDS, a BeforeTool
396
+ # matcher is a regex over the tool name, a lifecycle matcher an exact string (so
397
+ # none). A subagent is a tool of its own name; an MCP tool is
398
+ # `mcp_<server>_<tool>`. AfterAgent runs the integrity rule, which may ask a judge.
399
+ GEMINI_PRETOOL_MATCHER = ("^(run_shell_command|write_file|replace|grep_search|"
400
+ "search_file_content|codebase_investigator|generalist|"
401
+ "invoke_agent|mcp_.*)$")
402
+ GEMINI_HOOK_EVENTS = (("SessionStart", None, 5000), ("BeforeAgent", None, 5000),
403
+ ("BeforeTool", GEMINI_PRETOOL_MATCHER, 5000),
404
+ ("AfterTool", None, 5000), ("AfterAgent", None, 30000))
405
+ # Antigravity CLI (docs/hooks): `timeout` in seconds; a tool event is a matcher
406
+ # group (a regex over the tool name, "" for every tool), a lifecycle event a bare
407
+ # handler. The payload names no event, so the command does (argv[1]). An MCP
408
+ # tool's name is undocumented, so none is matched. Stop runs the integrity rule.
409
+ ANTIGRAVITY_PRETOOL_MATCHER = ("^(run_command|write_to_file|replace_file_content|"
410
+ "multi_replace_file_content|invoke_subagent|grep_search)$")
411
+ ANTIGRAVITY_HOOK_EVENTS = (("SessionStart", None, 5), ("PreInvocation", None, 5),
412
+ ("PreToolUse", ANTIGRAVITY_PRETOOL_MATCHER, 5),
413
+ ("PostToolUse", "", 5), ("Stop", None, 30))
414
+ # Goose (docs guides/context-engineering/hooks): `timeout` in seconds, a matcher a
415
+ # regex tested unanchored (so anchored here) against the tool name. The built-in
416
+ # developer tools are bare (`shell`, `write`, `edit`), an extension's tool is
417
+ # `<extension>__<tool>`. Only PreToolUse and Stop can block; Stop runs the
418
+ # integrity rule. `on_failure` is left at its default `allow` (X-D2).
419
+ GOOSE_PRETOOL_MATCHER = "^(shell|write|edit|.+__.+)$"
420
+ GOOSE_HOOK_EVENTS = (("SessionStart", None, 5), ("UserPromptSubmit", None, 5),
421
+ ("PreToolUse", GOOSE_PRETOOL_MATCHER, 5), ("PostToolUse", None, 5),
422
+ ("PostToolUseFailure", None, 5), ("Stop", None, 30))
327
423
  # The feature selection for THIS run: None means "read config.json['features']",
328
424
  # and an absent key there means the registry's declared default, which is what
329
425
  # makes a config written before this existed behave exactly as it used to.
@@ -338,7 +434,20 @@ OK, NO = " ok ", " MISS "
338
434
 
339
435
 
340
436
  def mkdirp(p):
437
+ """makedirs, recording each directory level it creates under HOME (outside
438
+ tezgah's own config dir) so uninstall removes it once empty (OQ-13)."""
439
+ made, d = [], os.path.abspath(p)
440
+ while not os.path.lexists(d) and d != os.path.dirname(d):
441
+ made.append(d)
442
+ d = os.path.dirname(d)
341
443
  os.makedirs(p, exist_ok=True)
444
+ made = [d for d in made if not UNINSTALLING and d.startswith(tp.HOME + os.sep)
445
+ and d != tp.CONFIG_DIR and not d.startswith(tp.CONFIG_DIR + os.sep)]
446
+ if made:
447
+ store = read_json(PRIOR, {})
448
+ for d in made:
449
+ store[d + os.sep] = {"created": True}
450
+ _prior_save(store)
342
451
 
343
452
 
344
453
  def expand(p):
@@ -468,14 +577,22 @@ def hook_entries(hosts):
468
577
  what re-arming would change."""
469
578
  out = {}
470
579
  for h in hosts:
471
- if h in ("codex", "cursor"):
472
- groups = codex_hook_groups() if h == "codex" else cursor_hook_entries()
580
+ if h in ("codex", "cursor", "qwen", "droid", "commandcode", "copilot", "gemini",
581
+ "antigravity", "goose"):
582
+ groups = {"codex": codex_hook_groups, "cursor": cursor_hook_entries,
583
+ "qwen": qwen_hook_groups, "droid": droid_hook_groups,
584
+ "commandcode": commandcode_hook_groups,
585
+ "copilot": copilot_hook_entries, "gemini": gemini_hook_groups,
586
+ "antigravity": antigravity_hook_groups,
587
+ "goose": goose_hook_groups}[h]()
473
588
  out[h] = ta.own_groups({"hooks": {ev: [g] for ev, g in groups.items()}})
474
589
  elif h == "opencode":
475
590
  src = os.path.join(HERE, "hosts", "opencode", "plugins", "tezgah.js")
476
591
  out[h] = {d: src for d in ta.OPENCODE_DIRS}
477
592
  elif h == "omp":
478
593
  out[h] = {"bridge": omp_bridge()}
594
+ elif h == "amp":
595
+ out[h] = {"plugin": ta.amp_plugin(HERE)}
479
596
  else:
480
597
  out[h] = ta.registration(h, HERE)[0] or {}
481
598
  return out
@@ -559,6 +676,132 @@ def record_materialised(path, add=True):
559
676
  pass
560
677
 
561
678
 
679
+ def _prior_save(store):
680
+ """Write the install-prior record whole (temp file, then replace); an
681
+ emptied record is removed, so a finished uninstall leaves no file."""
682
+ try:
683
+ if not store:
684
+ if os.path.exists(PRIOR):
685
+ os.remove(PRIOR)
686
+ return
687
+ mkdirp(tp.CONFIG_DIR)
688
+ with open(PRIOR + ".tmp", "w", encoding="utf-8") as fh:
689
+ # no sort_keys: a recorded value comes back in its own key order
690
+ fh.write(json.dumps(store, indent=2, ensure_ascii=False) + "\n")
691
+ os.replace(PRIOR + ".tmp", PRIOR)
692
+ except OSError:
693
+ pass
694
+
695
+
696
+ def _key_chain(key):
697
+ """"a/b/c" -> ["a", "a/b", "a/b/c"]: the key and every parent level."""
698
+ parts = key.split("/")
699
+ return ["/".join(parts[:i]) for i in range(1, len(parts) + 1)]
700
+
701
+
702
+ def _prior_entry(store, path):
703
+ """The record of one host file, made empty when absent or not an object:
704
+ a hand-edited or damaged entry reads as no record, never a traceback."""
705
+ entry = store.get(path)
706
+ if not isinstance(entry, dict):
707
+ entry = store[path] = {}
708
+ return entry
709
+
710
+
711
+ def note_created(path):
712
+ """Record, the first time tezgah writes a host file, whether the file was
713
+ there before. The first answer stands: a later write finds tezgah's own file.
714
+ A file already carrying tezgah's name was written by an install older than
715
+ the record, so it gets none: its uninstall does what it always did."""
716
+ real = os.path.abspath(path)
717
+ if UNINSTALLING or real.startswith(tp.CONFIG_DIR + os.sep) \
718
+ or not real.startswith(tp.HOME + os.sep):
719
+ return
720
+ store = read_json(PRIOR, {})
721
+ entry = _prior_entry(store, path)
722
+ if "created" in entry:
723
+ return
724
+ if os.path.lexists(path):
725
+ try:
726
+ with open(path, encoding="utf-8", errors="replace") as fh:
727
+ if "tezgah" in fh.read():
728
+ return
729
+ except OSError:
730
+ pass
731
+ entry["created"] = not os.path.lexists(path)
732
+ _prior_save(store)
733
+
734
+
735
+ def remember_prior(path, data, key):
736
+ """Record what `data` - the object read from `path` before tezgah edits
737
+ it - holds at `key`, once: a later install finds tezgah's own value there,
738
+ so the first answer is the user's. A missing level is recorded as ABSENT at
739
+ the first level that is missing, which tells restore_prior() the parents
740
+ tezgah created too."""
741
+ store = read_json(PRIOR, {})
742
+ entry = _prior_entry(store, path)
743
+ chain = _key_chain(key)
744
+ if any(k in entry for k in chain):
745
+ return
746
+ node = data
747
+ for k in chain:
748
+ part = k.rsplit("/", 1)[-1]
749
+ if not isinstance(node, dict) or part not in node:
750
+ entry[k] = ABSENT
751
+ break
752
+ node = node[part]
753
+ else:
754
+ entry[key] = node
755
+ _prior_save(store)
756
+
757
+
758
+ def restore_prior(path, data, key, keep_unrecorded=False):
759
+ """Put `key` of `data` back to what the install found: the user's value, or
760
+ no key at all, with every parent level the install created and left empty.
761
+ An install older than the record left none: the key goes with its emptied
762
+ parents (what uninstall always did), or stays with `keep_unrecorded`.
763
+ Returns True when `data` changed; the record forgets the key either way."""
764
+ store = read_json(PRIOR, {})
765
+ entry = _prior_entry(store, path)
766
+ chain = _key_chain(key)
767
+ rec = next((k for k in chain if k in entry), None)
768
+ parts = key.split("/")
769
+ nodes = [data]
770
+ for part in parts[:-1]:
771
+ nodes.append(nodes[-1].get(part) if isinstance(nodes[-1], dict) else None)
772
+ before = json.dumps(data, sort_keys=True)
773
+ if isinstance(nodes[-1], dict) and not (rec is None and keep_unrecorded):
774
+ if rec == key and entry[rec] != ABSENT:
775
+ nodes[-1][parts[-1]] = entry[rec]
776
+ else:
777
+ nodes[-1].pop(parts[-1], None)
778
+ floor = len(rec.split("/")) if rec else 1
779
+ for depth in range(len(parts) - 1, 0, -1):
780
+ if depth < floor or nodes[depth]:
781
+ break
782
+ nodes[depth - 1].pop(parts[depth - 1])
783
+ if rec is not None:
784
+ for k in chain:
785
+ entry.pop(k, None)
786
+ _prior_save(store)
787
+ return json.dumps(data, sort_keys=True) != before
788
+
789
+
790
+ def prior_created(path):
791
+ """True when the record says tezgah created this host file, False when it
792
+ says the file was there, None with no record (an install older than it)."""
793
+ entry = read_json(PRIOR, {}).get(path)
794
+ created = entry.get("created") if isinstance(entry, dict) else None
795
+ return created if isinstance(created, bool) else None
796
+
797
+
798
+ def forget_prior(path):
799
+ """Drop the record of one host file once its uninstall is done."""
800
+ store = read_json(PRIOR, {})
801
+ if store.pop(path, None) is not None:
802
+ _prior_save(store)
803
+
804
+
562
805
  def is_tezgah_copy(path):
563
806
  """True when `path` is a real entry this installer materialised where a
564
807
  symlink was refused. A same-named file tezgah never wrote is the user's."""
@@ -647,39 +890,98 @@ def read_json(path, default):
647
890
  return data
648
891
 
649
892
 
650
- # Every host config read_config refused this run; a non-empty list fails it.
893
+ # Every host file this run refused to rewrite; a non-empty list fails it.
651
894
  REFUSED = []
652
895
 
653
896
 
654
- def read_config(path, empty):
897
+ def read_failure(exc):
898
+ """The cause a host-file read failed with, in the words a refusal names."""
899
+ if isinstance(exc, UnicodeDecodeError):
900
+ return "not UTF-8 text"
901
+ if isinstance(exc, OSError):
902
+ return "cannot read it: %s" % (exc.strerror or exc)
903
+ return "not valid JSON: %s" % exc
904
+
905
+
906
+ def refuse(path, why, rerun="--install"):
907
+ """Name a host file tezgah will not touch and why; an install records it in
908
+ REFUSED, which fails the run."""
909
+ print("%s %s (%s; left untouched - fix it, then re-run %s)" % (NO, path, why, rerun))
910
+ if rerun == "--install":
911
+ REFUSED.append(path)
912
+
913
+
914
+ def read_config(path, empty, comments=False, rerun="--install"):
655
915
  """A host config tezgah is about to rewrite: `empty` when the file is absent,
656
916
  its object when it parses as one, None otherwise - named, recorded in
657
917
  REFUSED, and never written, because a write would replace the user's file
658
- with tezgah's default (a trailing comma once cost a whole opencode.json)."""
918
+ with tezgah's default (a trailing comma once cost a whole opencode.json).
919
+ With `comments` (a host that reads JSON with comments) a file holding
920
+ comments is valid as it is: named and left untouched, not refused."""
659
921
  try:
660
922
  with open(path, encoding="utf-8") as fh:
661
- data = json.load(fh)
923
+ text = fh.read()
924
+ data = json.loads(text)
662
925
  except FileNotFoundError:
663
926
  return empty
664
927
  except (OSError, ValueError) as exc:
665
- why = "not valid JSON: %s" % exc
928
+ if comments and not isinstance(exc, (OSError, UnicodeDecodeError)) \
929
+ and jsonc_loads(text) is not None:
930
+ print("%s %s holds comments - left untouched" % (NO, path))
931
+ return None
932
+ why = read_failure(exc)
666
933
  else:
667
934
  if isinstance(data, dict):
668
935
  return data
669
936
  why = "not a JSON object: the top level is %s" % type(data).__name__
670
- print("%s %s (%s; left untouched - fix it, then re-run --install)"
671
- % (NO, path, why))
672
- REFUSED.append(path)
937
+ refuse(path, why, rerun)
673
938
  return None
674
939
 
675
940
 
941
+ def _json_layout(path):
942
+ """(indent, separators, final newline) of the JSON file at `path`, so a
943
+ rewrite keeps the user's own layout: a one-line file stays one line (a
944
+ minified one stays minified), a 4-space or tab indent stays that. A new
945
+ file, `{}` or an unreadable one gets 2 spaces."""
946
+ try:
947
+ with open(path, encoding="utf-8") as fh:
948
+ text = fh.read()
949
+ except (OSError, ValueError):
950
+ return 2, None, True
951
+ body = text.strip()
952
+ if body in ("", "{}", "[]"):
953
+ return 2, None, True
954
+ m = re.search(r"\n([ \t]*)\S", body)
955
+ if m:
956
+ return m.group(1), None, text.endswith("\n")
957
+ # ponytail: a string value holding ": " misreads a minified file as spaced
958
+ tight = not re.search(r"[:,] ", body)
959
+ return None, ((",", ":") if tight else None), text.endswith("\n")
960
+
961
+
676
962
  def write_json(path, data):
677
- """Write JSON, skipping the write when the bytes are already there.
963
+ """Write JSON in the file's own layout, skipping the write when the bytes
964
+ are already there.
678
965
 
679
966
  An unchanged write is not free: it moves a host file's mtime and lays down a
680
967
  `.tezgah-bak`, so a command whose whole point is idempotence (a repeated
681
- --disable) would report a change it never made. Returns True when it wrote."""
682
- text = json.dumps(data, indent=2) + "\n"
968
+ --disable) would report a change it never made. The first write of a host
969
+ file records whether tezgah created it (note_created). Returns True when it
970
+ wrote."""
971
+ note_created(path)
972
+ # ponytail: one indent for the whole file - an inline `[1, 2]` or
973
+ # `{"a": 1}` inside an indented file comes back expanded (same data);
974
+ # keeping it would need a layout-preserving JSON writer
975
+ indent, seps, newline = _json_layout(path)
976
+ return write_text(path, json.dumps(data, indent=indent, separators=seps,
977
+ ensure_ascii=False)
978
+ + ("\n" if newline else ""))
979
+
980
+
981
+ def write_text(path, text):
982
+ """write_json's write for text already rendered (a JSON-with-comments file
983
+ edited in place): unchanged bytes skip it, a backup comes first, and the
984
+ file keeps its mode. Returns True when it wrote."""
683
985
  try:
684
986
  with open(path, encoding="utf-8") as fh:
685
987
  if fh.read() == text:
@@ -689,8 +991,15 @@ def write_json(path, data):
689
991
  mkdirp(os.path.dirname(path))
690
992
  backup(path)
691
993
  tmp = path + ".tmp"
692
- with open(tmp, "w", encoding="utf-8") as fh:
994
+ existed = os.path.exists(path)
995
+ # over an existing file the copy is born owner-only, then takes the file's
996
+ # mode: a settings file holding provider keys is never readable by others,
997
+ # not even between the write and the mode copy
998
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600 if existed else 0o666)
999
+ with open(fd, "w", encoding="utf-8") as fh:
693
1000
  fh.write(text)
1001
+ if existed:
1002
+ shutil.copymode(path, tmp) # an owner-only config stays owner-only
694
1003
  os.replace(tmp, path)
695
1004
  return True
696
1005
 
@@ -727,7 +1036,8 @@ def backup_base(path):
727
1036
 
728
1037
  def backup(path):
729
1038
  """Copy `path` to `<backup_base>.<timestamp>.tezgah-bak` before a write
730
- changes it.
1039
+ changes it, owner-only: a host config can hold provider keys (Qwen's
1040
+ settings.json does), and a copy must not be readable where the file was not.
731
1041
 
732
1042
  The suffix stays `.tezgah-bak`, so managed() and the uninstall cleanup still
733
1043
  know it; past BACKUP_CAP the middle copies go, never the oldest."""
@@ -743,6 +1053,7 @@ def backup(path):
743
1053
  try:
744
1054
  mkdirp(os.path.dirname(dst))
745
1055
  shutil.copy2(path, dst)
1056
+ os.chmod(dst, 0o600)
746
1057
  d, name = os.path.split(base)
747
1058
  old = sorted(entry for entry in os.listdir(d or ".")
748
1059
  if (m := BACKUP_RE.fullmatch(entry)) and m.group(1) == name)
@@ -813,6 +1124,7 @@ def set_managed_block(path, block, start="# tezgah:start", end="# tezgah:end"):
813
1124
  out.pop()
814
1125
  if not out:
815
1126
  out = ["# dsh home patch layer - tezgah-setup manages the marked block below"]
1127
+ note_created(path)
816
1128
  mkdirp(os.path.dirname(path))
817
1129
  text = "\n".join(out) + "\n" + start + " (managed by tezgah-setup; do not edit)\n" \
818
1130
  + block.strip() + "\n" + end + "\n"
@@ -1076,7 +1388,13 @@ def install_common(new_roots, hosts):
1076
1388
  # older one, so an upgrade changes no one's reply language
1077
1389
  cfg["language"] = tp.env_language() if first else "tr"
1078
1390
  if own:
1079
- write_json(tp.CONFIG, cfg)
1391
+ try:
1392
+ write_json(tp.CONFIG, cfg)
1393
+ except OSError as exc:
1394
+ # a HOME the install cannot write: every later step would fail the
1395
+ # same way, so the run stops here and names the path (B-100)
1396
+ sys.exit("%s cannot write %s: %s"
1397
+ % (NO, exc.filename or tp.CONFIG, exc.strerror or exc))
1080
1398
  print("%s %s roots=%s" % (OK, tp.CONFIG, cfg["roots"]))
1081
1399
  for name, src in [
1082
1400
  ("consult", os.path.join(HERE, "bin", "consult")),
@@ -1089,6 +1407,9 @@ def install_common(new_roots, hosts):
1089
1407
  ("tezgah-rollback", os.path.join(HERE, "bin", "tezgah-rollback")),
1090
1408
  ("tezgah-agents", os.path.join(HERE, "bin", "tezgah-agents")),
1091
1409
  ("tezgah-context", os.path.join(HERE, "bin", "tezgah-context")),
1410
+ # cited by the contract skill and the taste hook as farm paths
1411
+ ("tezgah-optimize", os.path.join(HERE, "bin", "tezgah-optimize")),
1412
+ ("tezgah-taste", os.path.join(HERE, "bin", "tezgah-taste")),
1092
1413
  ("tezgah-capture", os.path.join(HERE, "bin", "tezgah-capture")),
1093
1414
  ("tezgah-doctor", os.path.join(HERE, "bin", "tezgah-doctor")),
1094
1415
  ("tezgah-setup", os.path.join(HERE, "bin", "tezgah-setup")),
@@ -1098,6 +1419,15 @@ def install_common(new_roots, hosts):
1098
1419
  ("tezgah-render-table", os.path.join(HERE, "skills", "plan-add", "render_table.py")),
1099
1420
  ("tezgah-codex-hook", os.path.join(HERE, "hosts", "codex", "hook.py")),
1100
1421
  ("tezgah-cursor-hook", os.path.join(HERE, "hosts", "cursor", "hook.py")),
1422
+ ("tezgah-qwen-hook", os.path.join(HERE, "hosts", "qwen", "hook.py")),
1423
+ ("tezgah-droid-hook", os.path.join(HERE, "hosts", "droid", "hook.py")),
1424
+ ("tezgah-commandcode-hook",
1425
+ os.path.join(HERE, "hosts", "commandcode", "hook.py")),
1426
+ ("tezgah-copilot-hook", os.path.join(HERE, "hosts", "copilot", "hook.py")),
1427
+ ("tezgah-gemini-hook", os.path.join(HERE, "hosts", "gemini", "hook.py")),
1428
+ ("tezgah-antigravity-hook",
1429
+ os.path.join(HERE, "hosts", "antigravity", "hook.py")),
1430
+ ("tezgah-goose-hook", os.path.join(HERE, "hosts", "goose", "hook.py")),
1101
1431
  ("tezgah-statusline", os.path.join(HERE, "statusline.py")),
1102
1432
  ("tezgah-triage", os.path.join(HERE, "bin", "tezgah-triage")),
1103
1433
  ("tezgah-migrate", os.path.join(HERE, "bin", "tezgah-migrate")),
@@ -1192,7 +1522,7 @@ def install_claude():
1192
1522
  # tracked project file. Claude's hook drops the core when the block is there
1193
1523
  # (`TEZGAH_CORE_IN_FILE`, hooks/projects-auto-init.py), so a session pays for
1194
1524
  # the contract once.
1195
- print("%s %s (always-on contract)" % (OK, write_rules("claude")))
1525
+ write_rules("claude")
1196
1526
  write_user_agents("claude")
1197
1527
  register_claude_plugin()
1198
1528
 
@@ -1355,7 +1685,8 @@ def wire_claude_attribution():
1355
1685
 
1356
1686
  def wire_claude_statusline():
1357
1687
  """Point Claude's statusLine at tezgah's wrapper (it forwards to Orca, then
1358
- appends the tezgah segment). Backed up; skipped when already correct."""
1688
+ appends the tezgah segment). The user's own statusLine is recorded first, so
1689
+ uninstall puts it back. Backed up; skipped when already correct."""
1359
1690
  sp = os.path.join(CLAUDE, "settings.json")
1360
1691
  settings = read_json(sp, None)
1361
1692
  if settings is None:
@@ -1365,6 +1696,8 @@ def wire_claude_statusline():
1365
1696
  if (settings.get("statusLine") or {}).get("command") == cmd:
1366
1697
  print("%s statusLine -> tezgah" % OK)
1367
1698
  return
1699
+ if "statusline.py" not in json.dumps(settings.get("statusLine") or {}):
1700
+ remember_prior(sp, settings, "statusLine")
1368
1701
  settings["statusLine"] = {"type": "command", "command": cmd}
1369
1702
  write_json(sp, settings)
1370
1703
  print("%s statusLine -> tezgah (previous kept as %s.<timestamp>.tezgah-bak)" % (OK, sp))
@@ -1411,7 +1744,7 @@ def install_codex():
1411
1744
  # SessionStart hook's context is one message, where this file is the
1412
1745
  # instructions Codex keeps for the whole session. Global, like omp's
1413
1746
  # RULES.md, because a per-project file would be a tracked project file.
1414
- print("%s %s (always-on contract)" % (OK, write_rules("codex")))
1747
+ write_rules("codex")
1415
1748
  write_user_agents("codex")
1416
1749
 
1417
1750
 
@@ -1536,9 +1869,11 @@ def strip_toml_mcp(path, names):
1536
1869
  return False
1537
1870
  out, skip, changed = [], False, False
1538
1871
  for line in lines:
1539
- m = re.match(r"\[mcp_servers\.([^\]]+)\]", line.strip())
1540
- if m:
1541
- skip = m.group(1) in names
1872
+ # every table header ends the one before it: a user table after a
1873
+ # tezgah [mcp_servers.*] one stays, and a tezgah sub-table goes with it
1874
+ if line.strip().startswith("["):
1875
+ m = re.match(r"\[mcp_servers\.([^\].]+)[\].]", line.strip())
1876
+ skip = bool(m) and m.group(1) in names
1542
1877
  changed = changed or skip
1543
1878
  if not skip:
1544
1879
  out.append(line)
@@ -1597,8 +1932,12 @@ def write_artifact(path, text, write):
1597
1932
 
1598
1933
  The writers are idempotent (write_if_changed, set_managed_md): a re-render
1599
1934
  that produces the same bytes moves no mtime and lays no backup. Returns True
1600
- when the file changed."""
1935
+ when the file changed, None when the writer refused it (named in REFUSED):
1936
+ a file never written gets no sha, so it still reads as stale."""
1937
+ refused = len(REFUSED)
1601
1938
  changed = write(path, text)
1939
+ if len(REFUSED) > refused:
1940
+ return None
1602
1941
  if recorded_shas().get(path) != _sha(text):
1603
1942
  record_contract(path, text)
1604
1943
  return changed
@@ -1613,7 +1952,7 @@ def opencode_contract():
1613
1952
  def contract_artifacts(hosts):
1614
1953
  """{path: (fresh render, writer)} for every always-on contract artifact
1615
1954
  these hosts read: the managed block of each static rules file (Claude,
1616
- Codex, omp) and opencode's contract and skill routers."""
1955
+ Codex, omp, Qwen, Droid) and opencode's contract and skill routers."""
1617
1956
  out = {}
1618
1957
  for h in hosts:
1619
1958
  if h in STATIC_RULES:
@@ -1933,7 +2272,10 @@ def install_opencode():
1933
2272
  {"$schema": "https://opencode.ai/config.json"})
1934
2273
  if cfg is None:
1935
2274
  return
2275
+ # a file that already carries tezgah's wiring holds tezgah's prune, not the
2276
+ # user's: an install with no record must not store it as the prior value
1936
2277
  instr = cfg.get("instructions") or []
2278
+ wired = any(isinstance(i, str) and "tezgah" in i for i in instr)
1937
2279
  for extra in (contract, SKILL_ROUTER):
1938
2280
  if extra not in instr:
1939
2281
  instr.append(extra)
@@ -1981,6 +2323,9 @@ def install_opencode():
1981
2323
  # "[Old tool result content cleared]" so a long session stops re-sending
1982
2324
  # every output on every step. setdefault keeps a user's explicit choice.
1983
2325
  if not v2: # opencode 2 omits it as an unsupported legacy setting
2326
+ comp = cfg.get("compaction")
2327
+ if not (wired and isinstance(comp, dict) and comp.get("prune") is True):
2328
+ remember_prior(os.path.join(OPENCODE, "opencode.json"), cfg, "compaction/prune")
1984
2329
  cfg.setdefault("compaction", {}).setdefault("prune", True)
1985
2330
  wat = cfg.setdefault("watcher", {})
1986
2331
  ign = wat.setdefault("ignore", [])
@@ -2052,6 +2397,12 @@ def install_cursor():
2052
2397
  cc = os.path.join(CURSOR, "cli-config.json")
2053
2398
  cfg = read_config(cc, {})
2054
2399
  if cfg is not None:
2400
+ # the user's own statusLine (and a file with no `version`) come back
2401
+ # at uninstall, from the install-prior record; a file already carrying
2402
+ # tezgah's statusLine holds tezgah's values, not the user's
2403
+ if "tezgah-statusline" not in json.dumps(cfg.get("statusLine") or {}):
2404
+ remember_prior(cc, cfg, "version")
2405
+ remember_prior(cc, cfg, "statusLine")
2055
2406
  cfg.setdefault("version", 1)
2056
2407
  cfg["statusLine"] = {
2057
2408
  "type": "command",
@@ -2087,6 +2438,512 @@ def install_cursor():
2087
2438
  restart_hint("cursor", mpath)
2088
2439
 
2089
2440
 
2441
+ def qwen_hook_groups():
2442
+ """{event: the group install_qwen writes}; `hook_entries` plans from it."""
2443
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-qwen-hook"))
2444
+ out = {}
2445
+ for ev, matcher, timeout in QWEN_HOOK_EVENTS:
2446
+ group = {"matcher": matcher} if matcher else {}
2447
+ group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
2448
+ out[ev] = group
2449
+ return out
2450
+
2451
+
2452
+ def install_qwen():
2453
+ """Qwen Code: hook groups and the status line in `settings.json`, skill
2454
+ links, and the always-on block in `QWEN.md`. settings.json is shared - Orca
2455
+ writes its own hooks there and the user keeps provider keys in it - so only a
2456
+ group naming tezgah is replaced, the test uninstall drops by, and the backup
2457
+ `write_json` lays down is owner-only (`backup`)."""
2458
+ print("\nqwen:")
2459
+ path = os.path.join(QWEN, "settings.json")
2460
+ data = read_config(path, {})
2461
+ if data is not None:
2462
+ hooks = data.setdefault("hooks", {})
2463
+ for ev, group in qwen_hook_groups().items():
2464
+ arr = hooks.setdefault(ev, [])
2465
+ arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
2466
+ arr.append(group)
2467
+ # Qwen's command status line: the session JSON on stdin, ANSI kept;
2468
+ # the user's own comes back at uninstall, from the install-prior record
2469
+ if "tezgah-statusline" not in json.dumps(data.get("ui") or {}):
2470
+ remember_prior(path, data, "ui/statusLine")
2471
+ data.setdefault("ui", {})["statusLine"] = {
2472
+ "type": "command",
2473
+ "command": tp.hook_command(
2474
+ os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --qwen",
2475
+ "respectUserColors": True,
2476
+ }
2477
+ write_json(path, data)
2478
+ print("%s %s (hooks + ui.statusLine)" % (OK, path))
2479
+ for s in SKILLS:
2480
+ print(link(os.path.join(HERE, "skills", s), os.path.join(QWEN, "skills", s)))
2481
+ write_rules("qwen")
2482
+
2483
+
2484
+ def droid_hook_groups():
2485
+ """{event: the group install_droid writes}; `hook_entries` plans from it."""
2486
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-droid-hook"))
2487
+ out = {}
2488
+ for ev, matcher, timeout in DROID_HOOK_EVENTS:
2489
+ group = {"matcher": matcher} if matcher else {}
2490
+ group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
2491
+ out[ev] = group
2492
+ return out
2493
+
2494
+
2495
+ # a JSON string, kept, or a `//` / `/* */` comment, dropped
2496
+ JSONC_TOKENS = re.compile(r'("(?:\\.|[^"\\])*")|//[^\n]*|/\*.*?\*/', re.S)
2497
+
2498
+
2499
+ def read_jsonc(path):
2500
+ """The object in a JSON-with-comments file (Droid's settings.json), {} when
2501
+ the file is absent, None when it does not parse. A whole rewrite would drop
2502
+ the user's comments, so the only writers are `jsonc_put`/`jsonc_drop`.
2503
+ ponytail: a trailing comma inside a string value is dropped too; the readers
2504
+ only ask for top-level keys."""
2505
+ try:
2506
+ with open(path, encoding="utf-8") as fh:
2507
+ text = fh.read()
2508
+ except FileNotFoundError:
2509
+ return {}
2510
+ except (OSError, ValueError): # unreadable, or not UTF-8
2511
+ return None
2512
+ return jsonc_loads(text)
2513
+
2514
+
2515
+ def droid_hooks_blocked():
2516
+ """Why tezgah must not create ~/.factory/hooks.json, or None. Droid reads the
2517
+ `hooks` key of settings.json only while hooks.json is absent, so creating one
2518
+ beside such a key would switch the user's own hooks off."""
2519
+ if os.path.exists(os.path.join(DROID, "hooks.json")):
2520
+ return None
2521
+ path = os.path.join(DROID, "settings.json")
2522
+ data = read_jsonc(path)
2523
+ if data is None:
2524
+ return "%s does not parse, so its `hooks` key cannot be ruled out" % path
2525
+ if "hooks" in data:
2526
+ return ("%s carries a `hooks` key, which Droid ignores once hooks.json "
2527
+ "exists - move those hooks into hooks.json, then re-run --install" % path)
2528
+ return None
2529
+
2530
+
2531
+ def droid_statusline():
2532
+ return tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --droid"
2533
+
2534
+
2535
+ def jsonc_loads(text):
2536
+ """read_jsonc's parse of text already read: the object, or None."""
2537
+ text = re.sub(r",(\s*[}\]])", r"\1", JSONC_TOKENS.sub(lambda m: m.group(1) or "", text))
2538
+ try:
2539
+ data = json.loads(text)
2540
+ except ValueError:
2541
+ return None
2542
+ return data if isinstance(data, dict) else None
2543
+
2544
+
2545
+ # the object's opening brace, past any comment or string before it
2546
+ JSONC_OPEN = re.compile(r'("(?:\\.|[^"\\])*")|//[^\n]*|/\*.*?\*/|(\{)', re.S)
2547
+
2548
+
2549
+ def jsonc_put(text, key, value):
2550
+ """`text` (JSON with comments) with the top-level `key` set to `value` and
2551
+ every comment kept, or None when that cannot be done safely: the file does
2552
+ not parse, the key holds an object or list, or the edit would change
2553
+ anything else. An absent key goes in as one line under the opening brace,
2554
+ the shape `jsonc_drop` takes back out."""
2555
+ data = jsonc_loads(text)
2556
+ if data is None:
2557
+ return None
2558
+ want = dict(data, **{key: value})
2559
+ if key in data:
2560
+ if data[key] == value:
2561
+ return text
2562
+ if isinstance(data[key], (dict, list)):
2563
+ return None
2564
+ new = re.sub(r'("%s"\s*:\s*)%s' % (re.escape(key), re.escape(json.dumps(data[key]))),
2565
+ lambda m: m.group(1) + json.dumps(value), text, count=1)
2566
+ else:
2567
+ brace = next((m for m in JSONC_OPEN.finditer(text) if m.group(2)), None)
2568
+ if brace is None:
2569
+ return None
2570
+ line = "\n %s: %s%s" % (json.dumps(key), json.dumps(value), "," if data else "")
2571
+ new = text[:brace.end()] + line + text[brace.end():]
2572
+ return new if jsonc_loads(new) == want else None
2573
+
2574
+
2575
+ def jsonc_drop(text, key, value):
2576
+ """`text` without the one-line `key: value` member `jsonc_put` inserted, or
2577
+ None when that exact line is not there or its removal changes anything else."""
2578
+ data = jsonc_loads(text)
2579
+ if data is None or data.get(key) != value:
2580
+ return None
2581
+ new = re.sub(r'\n[ \t]*%s: %s,?' % (re.escape(json.dumps(key)), re.escape(json.dumps(value))),
2582
+ "", text, count=1)
2583
+ # the line's own comma may now sit before the closing brace (comments may
2584
+ # follow it): take it out, unless the file already carried one there
2585
+ tail = r",(\s*}(?:\s|//[^\n]*|/\*.*?\*/)*)$"
2586
+ if not re.search(tail, text, re.S):
2587
+ new = re.sub(tail, r"\1", new, flags=re.S)
2588
+ rest = dict(data)
2589
+ rest.pop(key)
2590
+ return new if jsonc_loads(new) == rest else None
2591
+
2592
+
2593
+ def install_droid():
2594
+ """Factory Droid: hook groups in `~/.factory/hooks.json`, the status line in
2595
+ `settings.json`, skill links, and the always-on block in `AGENTS.md`.
2596
+ hooks.json may hold the user's own groups, so only a group naming tezgah is
2597
+ replaced. It is not created while settings.json declares hooks
2598
+ (`droid_hooks_blocked`). settings.json is JSON with comments, so the status
2599
+ line and `includeCoAuthoredByDroid: false` (no co-author trailer: the
2600
+ no-attribution rule) are edited in place with every comment kept
2601
+ (`jsonc_put`); a file that cannot be edited safely is left alone and named."""
2602
+ print("\ndroid:")
2603
+ path = os.path.join(DROID, "hooks.json")
2604
+ why = droid_hooks_blocked()
2605
+ if why:
2606
+ print("%s %s not written: %s" % (NO, path, why))
2607
+ else:
2608
+ data = read_config(path, {})
2609
+ if data is not None:
2610
+ for ev, group in droid_hook_groups().items():
2611
+ arr = data.setdefault(ev, [])
2612
+ arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
2613
+ arr.append(group)
2614
+ write_json(path, data)
2615
+ print("%s %s (hooks)" % (OK, path))
2616
+ spath = os.path.join(DROID, "settings.json")
2617
+ try:
2618
+ with open(spath, encoding="utf-8") as fh:
2619
+ text = fh.read()
2620
+ except FileNotFoundError:
2621
+ text = "{}\n"
2622
+ except (OSError, ValueError): # unreadable, or not UTF-8
2623
+ text = None
2624
+ # the command's stdout is drawn above the input (settings reference)
2625
+ line = {"command": droid_statusline()}
2626
+ try:
2627
+ plain = json.loads(text) if text is not None else None
2628
+ except ValueError:
2629
+ plain = None # comments: edit in place
2630
+ if why:
2631
+ pass # the install stopped at the user's own `hooks` key: settings stays as it is
2632
+ elif isinstance(plain, dict):
2633
+ # plain JSON (an earlier install wrote it): set both keys and rewrite, as before
2634
+ ours = "tezgah-statusline" in json.dumps(plain.get("statusLine") or "")
2635
+ # the user's own trailer setting comes back at uninstall (OQ-15); a false
2636
+ # beside tezgah's own status line is the one an older install wrote
2637
+ if not (ours and plain.get("includeCoAuthoredByDroid") is False):
2638
+ remember_prior(spath, plain, "includeCoAuthoredByDroid")
2639
+ if ours or "statusLine" not in plain:
2640
+ plain["statusLine"] = line
2641
+ plain["includeCoAuthoredByDroid"] = False
2642
+ write_json(spath, plain)
2643
+ print("%s %s (statusLine%s, includeCoAuthoredByDroid false)" % (
2644
+ OK, spath, "" if plain["statusLine"] == line else " left as the user set it"))
2645
+ elif text is not None:
2646
+ found = jsonc_loads(text)
2647
+ old = (found or {}).get("statusLine")
2648
+ if isinstance(found, dict) and not (
2649
+ "tezgah-statusline" in json.dumps(old or "")
2650
+ and found.get("includeCoAuthoredByDroid") is False):
2651
+ remember_prior(spath, found, "includeCoAuthoredByDroid")
2652
+ stale = old != line and "tezgah-statusline" in json.dumps(old or "")
2653
+ if stale:
2654
+ dropped = jsonc_drop(text, "statusLine", old) # an earlier install's line
2655
+ text, stale = (dropped, False) if dropped is not None else (text, True)
2656
+ # each key on its own: a user's own statusLine never blocks the trailer switch
2657
+ new = jsonc_put(text, "statusLine", line) or text
2658
+ new = jsonc_put(new, "includeCoAuthoredByDroid", False) or new
2659
+ if new != text:
2660
+ write_text(spath, new)
2661
+ done = jsonc_loads(new) or {}
2662
+ print("%s %s (statusLine%s, includeCoAuthoredByDroid %s)" % (
2663
+ OK if done.get("includeCoAuthoredByDroid") is False and not stale else NO, spath,
2664
+ "" if done.get("statusLine") == line else
2665
+ " from an earlier install could not be refreshed: set it with /statusline to `%s`"
2666
+ % line["command"] if stale else " left as the user set it",
2667
+ "false" if done.get("includeCoAuthoredByDroid") is False else "not set: edit by hand"))
2668
+ else:
2669
+ print("%s %s cannot be read: set the status line with /statusline to `%s` and "
2670
+ "`includeCoAuthoredByDroid` to false" % (NO, spath, droid_statusline()))
2671
+ for s in SKILLS:
2672
+ print(link(os.path.join(HERE, "skills", s), os.path.join(DROID, "skills", s)))
2673
+ write_rules("droid")
2674
+
2675
+
2676
+ def commandcode_hook_groups():
2677
+ """{event: the group install_commandcode writes}; `hook_entries` plans from it."""
2678
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-commandcode-hook"))
2679
+ out = {}
2680
+ for ev, matcher, timeout in COMMANDCODE_HOOK_EVENTS:
2681
+ group = {"matcher": matcher} if matcher else {}
2682
+ group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
2683
+ out[ev] = group
2684
+ return out
2685
+
2686
+
2687
+ def install_commandcode():
2688
+ """Command Code: hook groups in the `hooks` key of
2689
+ `~/.commandcode/settings.json`, skill links, and the always-on block in
2690
+ `AGENTS.md`. settings.json is the user's file, so only a group naming tezgah
2691
+ is replaced and the backup is owner-only (`backup`). The same write turns
2692
+ off the default `Co-authored-by: CommandCodeBot` commit trailer
2693
+ (`attribution.commit` "", docs/settings) unless the user set a value."""
2694
+ print("\ncommandcode:")
2695
+ path = os.path.join(COMMANDCODE, "settings.json")
2696
+ data = read_config(path, {})
2697
+ if data is not None:
2698
+ attr = data.get("attribution")
2699
+ # hooks already naming tezgah: the "" commit beside them is the one an
2700
+ # older install wrote, not the user's prior value
2701
+ ours = "tezgah" in json.dumps(data.get("hooks") or {}) \
2702
+ and isinstance(attr, dict) and attr.get("commit") == ""
2703
+ hooks = data.setdefault("hooks", {})
2704
+ for ev, group in commandcode_hook_groups().items():
2705
+ arr = hooks.setdefault(ev, [])
2706
+ arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
2707
+ arr.append(group)
2708
+ # a commit value the user already had comes back at uninstall (OQ-15)
2709
+ if not ours:
2710
+ remember_prior(path, data, "attribution/commit")
2711
+ attr = data.setdefault("attribution", {})
2712
+ if isinstance(attr, dict) and "commit" not in attr:
2713
+ attr["commit"] = ""
2714
+ write_json(path, data)
2715
+ print("%s %s (hooks + attribution.commit)" % (OK, path))
2716
+ if not isinstance(attr, dict) or attr.get("commit") != "":
2717
+ print("%s %s keeps your attribution.commit, so commits still carry a "
2718
+ "co-author trailer" % (NO, path))
2719
+ for s in SKILLS:
2720
+ print(link(os.path.join(HERE, "skills", s), os.path.join(COMMANDCODE, "skills", s)))
2721
+ write_rules("commandcode")
2722
+
2723
+
2724
+ def copilot_hook_entries():
2725
+ """{event: the entry install_copilot writes}; `hook_entries` plans from it."""
2726
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-copilot-hook"))
2727
+ out = {}
2728
+ for ev, matcher, timeout in COPILOT_HOOK_EVENTS:
2729
+ entry = {"type": "command", "timeoutSec": timeout,
2730
+ # a camelCase payload names no event, so the command does
2731
+ "command": cmd + " " + ev if ev[0].islower() else cmd}
2732
+ if matcher:
2733
+ entry["matcher"] = matcher
2734
+ out[ev] = entry
2735
+ return out
2736
+
2737
+
2738
+ def copilot_statusline():
2739
+ return tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --copilot"
2740
+
2741
+
2742
+ def install_copilot():
2743
+ """GitHub Copilot CLI: `$COPILOT_HOME/hooks/tezgah.json`, a hooks file wholly
2744
+ tezgah's (the inline `hooks` key of settings.json is never used: one bad item
2745
+ there rejects every hook in it), the status line and `includeCoAuthoredBy`
2746
+ in settings.json, skill links, and the always-on block in
2747
+ `copilot-instructions.md`. settings.json is JSON with comments, so it is
2748
+ written only while it parses as plain JSON; `includeCoAuthoredBy` (default
2749
+ true, a `Co-authored-by` trailer on the agent's commits) is set false unless
2750
+ the user set it."""
2751
+ print("\ncopilot:")
2752
+ path = os.path.join(COPILOT, "hooks", "tezgah.json")
2753
+ write_json(path, {"version": 1, "hooks": {ev: [e] for ev, e in
2754
+ copilot_hook_entries().items()}})
2755
+ print("%s %s (hooks)" % (OK, path))
2756
+ spath = os.path.join(COPILOT, "settings.json")
2757
+ settings = read_config(spath, {}, comments=True)
2758
+ if isinstance(settings, dict):
2759
+ wired = "tezgah-statusline" in json.dumps(settings.get("statusLine") or {})
2760
+ if not wired:
2761
+ remember_prior(spath, settings, "statusLine")
2762
+ settings["statusLine"] = {"type": "command", "command": copilot_statusline()}
2763
+ # a value the user already had comes back at uninstall (OQ-15); a false
2764
+ # beside tezgah's own status line is the one an older install wrote
2765
+ if not (wired and settings.get("includeCoAuthoredBy") is False):
2766
+ remember_prior(spath, settings, "includeCoAuthoredBy")
2767
+ settings.setdefault("includeCoAuthoredBy", False)
2768
+ write_json(spath, settings)
2769
+ print("%s %s (statusLine + includeCoAuthoredBy)" % (OK, spath))
2770
+ if settings["includeCoAuthoredBy"] is not False:
2771
+ print("%s %s keeps your includeCoAuthoredBy, so commits still carry a "
2772
+ "co-author trailer" % (NO, spath))
2773
+ else:
2774
+ print("%s set `statusLine` to `%s` and `includeCoAuthoredBy` to false in %s "
2775
+ "by hand" % (NO, copilot_statusline(), spath))
2776
+ for s in SKILLS:
2777
+ print(link(os.path.join(HERE, "skills", s), os.path.join(COPILOT, "skills", s)))
2778
+ write_rules("copilot")
2779
+
2780
+
2781
+ def gemini_hook_groups():
2782
+ """{event: the group install_gemini writes}; `hook_entries` plans from it."""
2783
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-gemini-hook"))
2784
+ out = {}
2785
+ for ev, matcher, timeout in GEMINI_HOOK_EVENTS:
2786
+ group = {"matcher": matcher} if matcher else {}
2787
+ group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
2788
+ out[ev] = group
2789
+ return out
2790
+
2791
+
2792
+ def install_gemini():
2793
+ """Gemini CLI: tezgah's own extension dir, `gemini-extension.json` beside
2794
+ `hooks/hooks.json` and `skills/` links, wholly tezgah's; and the always-on
2795
+ block in `GEMINI.md`. settings.json is never written: it is JSON with
2796
+ comments and holds the user's keys. Gemini's default prompt adds no
2797
+ co-author trailer and no setting names one, so there is nothing to turn off."""
2798
+ print("\ngemini:")
2799
+ write_json(os.path.join(GEMINI_EXT, "gemini-extension.json"),
2800
+ {"name": "tezgah", "version": plugin_version(),
2801
+ "description": "tezgah lifecycle hooks and skills"})
2802
+ path = os.path.join(GEMINI_EXT, "hooks", "hooks.json")
2803
+ write_json(path, {"hooks": {ev: [g] for ev, g in gemini_hook_groups().items()}})
2804
+ print("%s %s (extension hooks)" % (OK, path))
2805
+ for s in SKILLS:
2806
+ print(link(os.path.join(HERE, "skills", s), os.path.join(GEMINI_EXT, "skills", s)))
2807
+ write_rules("gemini")
2808
+
2809
+
2810
+ def antigravity_hook_groups():
2811
+ """{event: the item install_antigravity writes under hooks.json's `tezgah`
2812
+ name}: a matcher group for a tool event, a bare handler for the others
2813
+ (docs/hooks "Schema and file format"); `hook_entries` plans from it."""
2814
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-antigravity-hook"))
2815
+ out = {}
2816
+ for ev, matcher, timeout in ANTIGRAVITY_HOOK_EVENTS:
2817
+ handler = {"type": "command", "command": cmd + " " + ev, "timeout": timeout}
2818
+ out[ev] = handler if matcher is None else {"matcher": matcher, "hooks": [handler]}
2819
+ return out
2820
+
2821
+
2822
+ def antigravity_statusline():
2823
+ return tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --antigravity"
2824
+
2825
+
2826
+ def install_antigravity():
2827
+ """Antigravity CLI: tezgah's hook set under the top-level `tezgah` name of
2828
+ `~/.gemini/config/hooks.json`, beside the user's other names (hooks in
2829
+ antigravity-cli/settings.json are never loaded, docs/hooks); the Antigravity
2830
+ App and IDE read that file too. The status line in
2831
+ `~/.gemini/antigravity-cli/settings.json`, written only while it is plain
2832
+ JSON; skill links in `antigravity-cli/skills`; the always-on block in the
2833
+ `~/.gemini/GEMINI.md` Gemini CLI shares. Antigravity adds no co-author
2834
+ trailer of its own and no setting names one, so there is nothing to turn off."""
2835
+ print("\nantigravity:")
2836
+ data = read_config(AGY_HOOKS, {})
2837
+ if data is not None:
2838
+ data["tezgah"] = {ev: [g] for ev, g in antigravity_hook_groups().items()}
2839
+ write_json(AGY_HOOKS, data)
2840
+ print("%s %s (hooks; the Antigravity App and IDE load them too)" % (OK, AGY_HOOKS))
2841
+ spath = os.path.join(ANTIGRAVITY, "settings.json")
2842
+ settings = read_config(spath, {}, comments=True)
2843
+ if isinstance(settings, dict):
2844
+ if "tezgah-statusline" not in json.dumps(settings.get("statusLine") or {}):
2845
+ remember_prior(spath, settings, "statusLine")
2846
+ settings["statusLine"] = {"type": "command", "command": antigravity_statusline()}
2847
+ write_json(spath, settings)
2848
+ print("%s %s (statusLine)" % (OK, spath))
2849
+ else:
2850
+ print("%s set `statusLine` to {\"type\": \"command\", \"command\": \"%s\"} in "
2851
+ "%s by hand" % (NO, antigravity_statusline(), spath))
2852
+ for s in SKILLS:
2853
+ print(link(os.path.join(HERE, "skills", s), os.path.join(ANTIGRAVITY, "skills", s)))
2854
+ write_rules("antigravity")
2855
+
2856
+
2857
+ def goose_hook_groups():
2858
+ """{event: the group install_goose writes}; `hook_entries` plans from it."""
2859
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-goose-hook"))
2860
+ out = {}
2861
+ for ev, matcher, timeout in GOOSE_HOOK_EVENTS:
2862
+ group = {"matcher": matcher} if matcher else {}
2863
+ group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
2864
+ out[ev] = group
2865
+ return out
2866
+
2867
+
2868
+ def install_goose():
2869
+ """Goose: tezgah's own plugin dir, `~/.agents/plugins/tezgah/`, wholly
2870
+ tezgah's - `plugin.json` beside `hooks/hooks.json` and `skills/` links (Goose
2871
+ loads them as `tezgah:<skill>`) - and the always-on block in
2872
+ `~/.config/goose/.goosehints`. The user's other `~/.agents` content is never
2873
+ touched. `on_failure` stays at its default `allow`, so a hook fault lets the
2874
+ call through. Neither Goose's docs nor its system prompt add a co-author
2875
+ trailer or name a setting for one, so there is nothing to turn off."""
2876
+ print("\ngoose:")
2877
+ write_json(os.path.join(GOOSE_PLUGIN, "plugin.json"),
2878
+ {"name": "tezgah", "version": plugin_version(),
2879
+ "description": "tezgah lifecycle hooks and skills"})
2880
+ path = os.path.join(GOOSE_PLUGIN, "hooks", "hooks.json")
2881
+ write_json(path, {"hooks": {ev: [g] for ev, g in goose_hook_groups().items()}})
2882
+ print("%s %s (plugin hooks)" % (OK, path))
2883
+ for s in SKILLS:
2884
+ print(link(os.path.join(HERE, "skills", s), os.path.join(GOOSE_PLUGIN, "skills", s)))
2885
+ write_rules("goose")
2886
+
2887
+
2888
+ def amp_settings(rerun="--install"):
2889
+ """(path, settings dict or None): Amp's user settings file as tezgah may
2890
+ rewrite it, the one answer for install, uninstall and the report.
2891
+ settings.jsonc stands in for settings.json when it is the only one; with
2892
+ both there, which Amp reads is undocumented and neither is touched. Absent
2893
+ reads as {}; a file holding comments is named and left untouched, an
2894
+ unreadable one refused. `rerun=None` (the report) reads without a word."""
2895
+ path = os.path.join(AMP, "settings.json")
2896
+ jsonc = os.path.join(AMP, "settings.jsonc")
2897
+ if os.path.exists(jsonc):
2898
+ if os.path.exists(path):
2899
+ if rerun:
2900
+ refuse(path, "%s exists beside it, and which of the two Amp reads "
2901
+ "is undocumented" % jsonc, rerun)
2902
+ return path, None
2903
+ path = jsonc
2904
+ if rerun is None:
2905
+ return path, read_jsonc(path)
2906
+ return path, read_config(path, {}, comments=True, rerun=rerun)
2907
+
2908
+
2909
+ def install_amp():
2910
+ """Amp: one system plugin file, `$XDG_CONFIG_HOME/amp/plugins/tezgah.ts`,
2911
+ rendered from `hosts/amp/tezgah-plugin.ts.in` (Amp loads every .ts there,
2912
+ nothing registers it); skill links in `~/.config/amp/skills`; the always-on
2913
+ block in `~/.config/amp/AGENTS.md`; and Amp's two commit trailers set off
2914
+ in settings.json unless the user set them."""
2915
+ print("\namp:")
2916
+ wired = amp_plugin_ours() # an earlier install's plugin, before this one writes
2917
+ plugin = ta.amp_plugin(HERE)
2918
+ if not plugin:
2919
+ print("%s %s: this tree has no hosts/amp/tezgah-plugin.ts.in to render"
2920
+ % (NO, AMP_PLUGIN))
2921
+ elif write_if_changed(AMP_PLUGIN, plugin):
2922
+ print("%s %s (plugin: gate + ledger + Stop rule)" % (OK, AMP_PLUGIN))
2923
+ print("%s amp: run `plugins: reload` in a running Amp to load it" % OK)
2924
+ spath, settings = amp_settings()
2925
+ if isinstance(settings, dict):
2926
+ for key in AMP_ATTRIBUTION:
2927
+ # a value the user already had comes back at uninstall (OQ-15); a
2928
+ # false beside tezgah's own plugin is the one an older install wrote
2929
+ if not (wired and settings.get(key) is False):
2930
+ remember_prior(spath, settings, key)
2931
+ settings.setdefault(key, False)
2932
+ write_json(spath, settings)
2933
+ print("%s %s (commit trailers off)" % (OK, spath))
2934
+ for key in AMP_ATTRIBUTION:
2935
+ if settings[key] is not False:
2936
+ print("%s %s keeps your %s, so the agent's commits still carry "
2937
+ "that trailer" % (NO, spath, key))
2938
+ else:
2939
+ print("%s set %s to false in %s by hand"
2940
+ % (NO, " and ".join("`%s`" % k for k in AMP_ATTRIBUTION), spath))
2941
+ for s in SKILLS:
2942
+ print(link(os.path.join(HERE, "skills", s), os.path.join(AMP, "skills", s)))
2943
+ write_rules("amp")
2944
+
2945
+
2946
+
2090
2947
  def dsh_app_mcp_rows():
2091
2948
  """One dsh-mcp-client row per MCP server tezgah writes into the block.
2092
2949
 
@@ -2330,12 +3187,17 @@ def write_if_changed(path, text):
2330
3187
  def set_managed_md(path, block, start="<!-- tezgah:start -->",
2331
3188
  end="<!-- tezgah:end -->"):
2332
3189
  """Replace the marked block in a markdown file, or append it, keeping any
2333
- user text around it. Markdown gets HTML-comment markers, not `#`."""
3190
+ user text around it. Markdown gets HTML-comment markers, not `#`. A file
3191
+ that is there but cannot be read (no read bit, not UTF-8) is refused, never
3192
+ replaced by the block alone. Returns True when the file changed."""
2334
3193
  try:
2335
3194
  with open(path, encoding="utf-8") as fh:
2336
3195
  lines = fh.read().splitlines()
2337
- except OSError:
3196
+ except FileNotFoundError:
2338
3197
  lines = []
3198
+ except (OSError, ValueError) as exc:
3199
+ refuse(path, read_failure(exc))
3200
+ return False
2339
3201
  out, skip = [], False
2340
3202
  for ln in lines:
2341
3203
  if ln.strip().startswith(start):
@@ -2432,7 +3294,10 @@ def strip_managed_md(path):
2432
3294
  try:
2433
3295
  with open(path, encoding="utf-8") as fh:
2434
3296
  text = fh.read()
2435
- except OSError:
3297
+ except FileNotFoundError:
3298
+ return
3299
+ except (OSError, ValueError) as exc:
3300
+ refuse(path, read_failure(exc), "--uninstall")
2436
3301
  return
2437
3302
  kept = re.sub(r"<!-- tezgah:start -->.*?<!-- tezgah:end -->\n?",
2438
3303
  "", text, flags=re.S).strip()
@@ -2471,7 +3336,32 @@ CLAUDE_RULES = os.path.join(CLAUDE, "CLAUDE.md")
2471
3336
  # and where that file is. A lambda per row, because codex_rules() answers from
2472
3337
  # the disk (AGENTS.override.md wins when it exists).
2473
3338
  STATIC_RULES = {"claude": lambda: CLAUDE_RULES, "codex": codex_rules,
2474
- "omp": lambda: os.path.join(OMP_AGENT, "RULES.md")}
3339
+ "omp": lambda: os.path.join(OMP_AGENT, "RULES.md"),
3340
+ "qwen": lambda: os.path.join(QWEN, "QWEN.md"),
3341
+ "droid": lambda: os.path.join(DROID, "AGENTS.md"),
3342
+ "commandcode": lambda: os.path.join(COMMANDCODE, "AGENTS.md"),
3343
+ "copilot": lambda: os.path.join(COPILOT, "copilot-instructions.md"),
3344
+ "gemini": lambda: os.path.join(GEMINI, "GEMINI.md"),
3345
+ "antigravity": lambda: os.path.join(os.path.dirname(ANTIGRAVITY), "GEMINI.md"),
3346
+ "goose": lambda: os.path.join(GOOSE, ".goosehints"),
3347
+ "amp": lambda: os.path.join(AMP, "AGENTS.md")}
3348
+ # Gemini CLI and Antigravity both load ~/.gemini/GEMINI.md, so they share ONE
3349
+ # managed block: an uninstall strips it only while no other host of this tuple
3350
+ # that reads the same file stays armed (`gemini_md_kept`; GEMINI_CLI_HOME moves
3351
+ # Gemini's copy away), and each host's uninstall asks the same question, so the
3352
+ # last one out takes the block.
3353
+ GEMINI_MD_HOSTS = ("gemini", "antigravity")
3354
+ # the hosts the running uninstall takes out (`uninstall` sets it)
3355
+ UNINSTALLING = ()
3356
+
3357
+
3358
+ def gemini_md_kept(host):
3359
+ """True while another GEMINI_MD_HOSTS host this uninstall does not touch is
3360
+ armed and reads `host`'s GEMINI.md."""
3361
+ armed = tp.config().get("hosts") or []
3362
+ path = STATIC_RULES[host]()
3363
+ return any(h in armed and h not in UNINSTALLING and STATIC_RULES[h]() == path
3364
+ for h in GEMINI_MD_HOSTS)
2475
3365
 
2476
3366
 
2477
3367
  def rules_block(host):
@@ -2481,10 +3371,11 @@ def rules_block(host):
2481
3371
 
2482
3372
 
2483
3373
  def write_rules(host):
2484
- """Render the host's block into its rules file and record its sha."""
3374
+ """Render the host's block into its rules file, record its sha and print
3375
+ its line; a refused file is named once, by the refusal, with no `ok`."""
2485
3376
  path = STATIC_RULES[host]()
2486
- write_artifact(path, rules_block(host), set_managed_md)
2487
- return path
3377
+ if write_artifact(path, rules_block(host), set_managed_md) is not None:
3378
+ print("%s %s (always-on contract)" % (OK, path))
2488
3379
 
2489
3380
 
2490
3381
  def rules_current(host):
@@ -2496,7 +3387,7 @@ def rules_current(host):
2496
3387
  try:
2497
3388
  with open(STATIC_RULES[host](), encoding="utf-8") as fh:
2498
3389
  lines = fh.read().splitlines()
2499
- except OSError:
3390
+ except (OSError, ValueError): # unreadable, or not UTF-8: not current
2500
3391
  return False
2501
3392
  starts = [i for i, ln in enumerate(lines)
2502
3393
  if ln.strip().startswith("<!-- tezgah:start -->")]
@@ -2517,7 +3408,7 @@ def omp_bridge():
2517
3408
  def install_omp():
2518
3409
  print("\nomp:")
2519
3410
  mkdirp(OMP_AGENT)
2520
- print("%s %s (always-on contract)" % (OK, write_rules("omp")))
3411
+ write_rules("omp")
2521
3412
  for s in SKILLS:
2522
3413
  print(link(os.path.join(HERE, "skills", s),
2523
3414
  os.path.join(OMP_AGENT, "skills", s)))
@@ -2586,7 +3477,11 @@ def install_omp():
2586
3477
 
2587
3478
  INSTALLERS = {"claude": install_claude, "codex": install_codex,
2588
3479
  "opencode": install_opencode, "cursor": install_cursor,
2589
- "dsh": install_dsh, "omp": install_omp}
3480
+ "dsh": install_dsh, "omp": install_omp, "qwen": install_qwen,
3481
+ "droid": install_droid, "commandcode": install_commandcode,
3482
+ "copilot": install_copilot, "gemini": install_gemini,
3483
+ "antigravity": install_antigravity, "goose": install_goose,
3484
+ "amp": install_amp}
2590
3485
 
2591
3486
 
2592
3487
  def install_openresearch(hosts):
@@ -3041,9 +3936,11 @@ def is_tezgah_link(path):
3041
3936
  wiring the user's."""
3042
3937
  if not os.path.islink(path):
3043
3938
  return False
3939
+ # every root resolved like the target: a HOME reached through a symlink
3940
+ # (macOS /tmp, Fedora's /home) otherwise reads tezgah's own hop as a stranger
3044
3941
  target = os.path.realpath(path)
3045
3942
  return any(target == root or target.startswith(root + os.sep)
3046
- for root in (os.path.realpath(HERE), INSTALL_PREFIX, tp.BIN_DIR))
3943
+ for root in map(os.path.realpath, (HERE, INSTALL_PREFIX, tp.BIN_DIR)))
3047
3944
 
3048
3945
 
3049
3946
  def is_tezgah_entry(path):
@@ -3083,6 +3980,9 @@ def unlink_dir(d):
3083
3980
  def drop_managed_hook_entries(path):
3084
3981
  """Remove only tezgah's own entries from a host hooks.json."""
3085
3982
  data = read_json(path, None)
3983
+ if data is not None and not isinstance(data, dict):
3984
+ print("%s %s left untouched (not a JSON object)" % (NO, path))
3985
+ return
3086
3986
  if not data:
3087
3987
  print("%s no %s" % (OK, path))
3088
3988
  return
@@ -3109,9 +4009,12 @@ def remove_managed_block(path, start="# tezgah:start", end="# tezgah:end"):
3109
4009
  try:
3110
4010
  with open(path, encoding="utf-8") as fh:
3111
4011
  lines = fh.read().splitlines()
3112
- except OSError:
4012
+ except FileNotFoundError:
3113
4013
  print("%s no %s" % (OK, path))
3114
4014
  return
4015
+ except (OSError, ValueError) as exc:
4016
+ refuse(path, read_failure(exc), "--uninstall")
4017
+ return
3115
4018
  out, skip, changed = [], False, False
3116
4019
  for ln in lines:
3117
4020
  stripped = ln.strip()
@@ -3134,8 +4037,17 @@ def remove_managed_block(path, start="# tezgah:start", end="# tezgah:end"):
3134
4037
  return
3135
4038
  while out and not out[-1].strip():
3136
4039
  out.pop()
3137
- # A dsh patch layer must stay a top-level YAML array; a file left holding
3138
- # only comments parses to null and the next dsh boot fails on it.
4040
+ # A dsh patch layer the install created goes whole once nothing but
4041
+ # tezgah's block was in it (OQ-13); one the user had must stay a top-level
4042
+ # YAML array: a file left holding only comments parses to null and the
4043
+ # next dsh boot fails on it.
4044
+ if not out and prior_created(path):
4045
+ backup(path)
4046
+ os.remove(path)
4047
+ forget_prior(path)
4048
+ print("%s removed %s (it held only tezgah's block)" % (OK, path))
4049
+ return
4050
+ forget_prior(path)
3139
4051
  if not any(ln.strip() and not ln.strip().startswith("#") for ln in out):
3140
4052
  out.append("[]")
3141
4053
  with open(path, "w", encoding="utf-8") as fh:
@@ -3144,8 +4056,6 @@ def remove_managed_block(path, start="# tezgah:start", end="# tezgah:end"):
3144
4056
 
3145
4057
 
3146
4058
  def uninstall_common():
3147
- print("\ncommon:")
3148
- unlink_dir(tp.BIN_DIR)
3149
4059
  removed = tezgah_agents.cleanup()
3150
4060
  if removed:
3151
4061
  print("%s removed %d generated repo agent file(s)" % (OK, removed))
@@ -3255,20 +4165,21 @@ def _drop_state_entry(path):
3255
4165
 
3256
4166
 
3257
4167
  def remove_generated_state(full):
3258
- """Take tezgah's state back: the cache dirs always, the config dir's
3259
- generated contents when the uninstall is full.
4168
+ """Take tezgah's state back when the uninstall is full: the cache dirs and
4169
+ the config dir's generated contents.
3260
4170
 
3261
4171
  `full` means the run covers every host config.json records - the armed set.
3262
- A partial uninstall keeps the config: the hosts still armed read their roots
3263
- and feature selection from it at runtime, and deleting it would degrade
4172
+ A partial uninstall keeps both: the hosts still armed read their roots and
4173
+ feature selection from the config and write their session state and app
4174
+ artifacts into the cache at runtime, and deleting either would degrade
3264
4175
  wiring this run did not touch. `adopted/` and any entry that is not one of
3265
4176
  tezgah's generated names survive in both cases."""
4177
+ if not full:
4178
+ return
3266
4179
  for d in (tp.CACHE, tp.fallback_cache()):
3267
4180
  if os.path.isdir(d):
3268
4181
  shutil.rmtree(d, ignore_errors=True)
3269
4182
  print("%s removed %s" % (OK, d))
3270
- if not full:
3271
- return
3272
4183
  try:
3273
4184
  names = sorted(os.listdir(tp.CONFIG_DIR))
3274
4185
  except OSError as exc:
@@ -3354,14 +4265,22 @@ def remove_install_tree():
3354
4265
  print("%s kept %s (holds %s)" % (NO, INSTALL_PREFIX, ", ".join(sorted(rest))))
3355
4266
 
3356
4267
 
3357
- def _text_clean(path, needle="tezgah"):
3358
- """(True, "") when `path` is gone or carries no `needle`; else the leftover."""
4268
+ def text_marks(path):
4269
+ """`path` as a host file can hold it: raw (TOML, markdown, a POSIX JSON
4270
+ string) and JSON-escaped, where a Windows path's backslashes are doubled."""
4271
+ return tuple(sorted({path, json.dumps(path)[1:-1]}))
4272
+
4273
+
4274
+ def _text_clean(path, needle):
4275
+ """(True, "") when `path` is gone or carries none of `needle` (one marker,
4276
+ or a tuple of them); else the leftover."""
3359
4277
  try:
3360
4278
  with open(path, encoding="utf-8", errors="replace") as fh:
3361
4279
  body = fh.read()
3362
4280
  except OSError:
3363
4281
  return True, ""
3364
- return needle not in body, path
4282
+ needles = (needle,) if isinstance(needle, str) else needle
4283
+ return not any(n in body for n in needles), path
3365
4284
 
3366
4285
 
3367
4286
  def verify_uninstall(hosts, full):
@@ -3381,7 +4300,21 @@ def verify_uninstall(hosts, full):
3381
4300
  else:
3382
4301
  print("%s %s" % (OK, label))
3383
4302
 
3384
- def clean(label, paths, needle="tezgah"):
4303
+ # the markers only tezgah writes, so a user's own text naming tezgah (a
4304
+ # Codex `[projects."…/tezgah"]` table, a note) is never a leftover: every
4305
+ # hook and status-line command runs from the farm under CONFIG_DIR, and an
4306
+ # MCP row is one of tezgah's server names (B-123)
4307
+ farm_mark = text_marks(tp.CONFIG_DIR + os.sep)
4308
+ names = [TEZGAH_MCP_NAME] + app_mcp_names()
4309
+ json_mcp = tuple('"%s":' % n for n in names)
4310
+ toml_mcp = tuple("[mcp_servers.%s]" % n for n in names)
4311
+
4312
+ def skills(root):
4313
+ """tezgah's own skill links under root; a user's same-named dir is theirs."""
4314
+ return [p for p in (os.path.join(root, "skills", s) for s in SKILLS)
4315
+ if is_tezgah_entry(p)]
4316
+
4317
+ def clean(label, paths, needle=farm_mark):
3385
4318
  left = []
3386
4319
  for p in paths:
3387
4320
  ok, leftover = _text_clean(p, needle)
@@ -3393,13 +4326,17 @@ def verify_uninstall(hosts, full):
3393
4326
  else:
3394
4327
  print("%s %s" % (OK, label))
3395
4328
 
3396
- # common state
4329
+ # common state: a partial run keeps the farm the armed hosts still run
4330
+ # from, and takes only the hook links of the hosts it removed
3397
4331
  farm = [os.path.join(tp.BIN_DIR, n)
3398
4332
  for n in (os.listdir(tp.BIN_DIR) if os.path.isdir(tp.BIN_DIR) else [])]
3399
- claim("config/bin farm holds no tezgah entry",
3400
- [p for p in farm if is_tezgah_entry(p)])
4333
+ gone = {"tezgah-%s-hook" % h for h in hosts}
4334
+ claim("config/bin farm holds no tezgah entry" if full
4335
+ else "config/bin farm holds no hook of these hosts",
4336
+ [p for p in farm if is_tezgah_entry(p)
4337
+ and (full or os.path.basename(p) in gone)])
3401
4338
  claim("materialised ledger gone", [MATERIALISED] if full else [])
3402
- claim("state caches gone", [tp.CACHE, tp.fallback_cache()])
4339
+ claim("state caches gone", [tp.CACHE, tp.fallback_cache()] if full else [])
3403
4340
  if full:
3404
4341
  try:
3405
4342
  has_tree = os.path.isdir(INSTALL_PREFIX) and any(
@@ -3423,11 +4360,11 @@ def verify_uninstall(hosts, full):
3423
4360
  for n in user_agent_files("claude")])
3424
4361
  if "codex" in hosts:
3425
4362
  clean("codex hooks.json unwired", [os.path.join(CODEX, "hooks.json")])
3426
- claim("codex skills unlinked",
3427
- [os.path.join(CODEX, "skills", s) for s in SKILLS
3428
- if os.path.lexists(os.path.join(CODEX, "skills", s))])
3429
- clean("codex config.toml unwired", [os.path.join(CODEX, "config.toml")])
3430
- claim("codex consult link gone", [os.path.join(CODEX, "bin", "consult")])
4363
+ claim("codex skills unlinked", skills(CODEX))
4364
+ clean("codex config.toml unwired", [os.path.join(CODEX, "config.toml")],
4365
+ needle=toml_mcp)
4366
+ consult = os.path.join(CODEX, "bin", "consult")
4367
+ claim("codex consult link gone", [consult] if is_tezgah_entry(consult) else [])
3431
4368
  # the user's own AGENTS.md may name tezgah; only the block is ours
3432
4369
  clean("codex global AGENTS.md block gone",
3433
4370
  [os.path.join(CODEX, n) for n in CODEX_RULES],
@@ -3441,20 +4378,24 @@ def verify_uninstall(hosts, full):
3441
4378
  + [os.path.join(OPENCODE, "tui-plugins", n)
3442
4379
  for n in ("tezgah-tui.tsx", "tezgah-cli")])
3443
4380
  clean("opencode opencode.json unwired",
3444
- [os.path.join(OPENCODE, "opencode.json")])
4381
+ [os.path.join(OPENCODE, "opencode.json")],
4382
+ needle=farm_mark + text_marks(os.path.join(HERE, "skills", "**")) + json_mcp)
3445
4383
  clean("opencode cli.json unwired", [os.path.join(OPENCODE, "cli.json")],
3446
4384
  needle=OPENCODE_CLI_PLUGIN)
3447
4385
  claim("opencode generated routers gone", [SKILL_ROUTER, SKILL_ROUTER_FULL])
3448
4386
  if "cursor" in hosts:
3449
4387
  clean("cursor hooks.json unwired", [os.path.join(CURSOR, "hooks.json")])
3450
- clean("cursor mcp.json unwired", [os.path.join(CURSOR, "mcp.json")])
4388
+ clean("cursor mcp.json unwired", [os.path.join(CURSOR, "mcp.json")],
4389
+ needle=json_mcp)
3451
4390
  clean("cursor cli-config.json unwired",
3452
4391
  [os.path.join(CURSOR, "cli-config.json")])
3453
4392
  if "dsh" in hosts:
3454
- clean("dsh patch layers unwired", [DSH_PATCH, DSH_WEB_PATCH])
4393
+ clean("dsh patch layers unwired", [DSH_PATCH, DSH_WEB_PATCH],
4394
+ needle="# tezgah:start")
3455
4395
  claim("dsh launcher gone", [dsh_launcher()[1]])
3456
4396
  if "omp" in hosts:
3457
- clean("omp RULES.md unwired", [os.path.join(OMP_AGENT, "RULES.md")])
4397
+ clean("omp RULES.md unwired", [os.path.join(OMP_AGENT, "RULES.md")],
4398
+ needle="<!-- tezgah:start")
3458
4399
  claim("omp hook script gone",
3459
4400
  [os.path.join(OMP_AGENT, "hooks", "pre", "tezgah-hook.ts")])
3460
4401
  agents = os.path.join(OMP_AGENT, "agents")
@@ -3462,13 +4403,61 @@ def verify_uninstall(hosts, full):
3462
4403
  [os.path.join(agents, n) for n in
3463
4404
  (os.listdir(agents) if os.path.isdir(agents) else [])
3464
4405
  if n[:-len(".md")] in tezgah_agents.OWNED_NAMES and n.endswith(".md")])
3465
- clean("omp mcp.json unwired", [os.path.join(OMP_AGENT, "mcp.json")])
3466
- claim("omp skills unlinked",
3467
- [os.path.join(OMP_AGENT, "skills", s) for s in SKILLS
3468
- if os.path.lexists(os.path.join(OMP_AGENT, "skills", s))])
4406
+ clean("omp mcp.json unwired", [os.path.join(OMP_AGENT, "mcp.json")],
4407
+ needle=json_mcp)
4408
+ claim("omp skills unlinked", skills(OMP_AGENT))
4409
+ if "qwen" in hosts:
4410
+ clean("qwen settings.json unwired", [os.path.join(QWEN, "settings.json")])
4411
+ clean("qwen global QWEN.md block gone", [os.path.join(QWEN, "QWEN.md")],
4412
+ needle="<!-- tezgah:start")
4413
+ claim("qwen skills unlinked", skills(QWEN))
4414
+ if "droid" in hosts:
4415
+ clean("droid hooks.json unwired", [os.path.join(DROID, "hooks.json")])
4416
+ clean("droid settings.json statusLine gone", [os.path.join(DROID, "settings.json")],
4417
+ needle="tezgah-statusline")
4418
+ clean("droid global AGENTS.md block gone", [os.path.join(DROID, "AGENTS.md")],
4419
+ needle="<!-- tezgah:start")
4420
+ claim("droid skills unlinked", skills(DROID))
4421
+ if "commandcode" in hosts:
4422
+ clean("commandcode settings.json unwired",
4423
+ [os.path.join(COMMANDCODE, "settings.json")])
4424
+ clean("commandcode global AGENTS.md block gone",
4425
+ [os.path.join(COMMANDCODE, "AGENTS.md")], needle="<!-- tezgah:start")
4426
+ claim("commandcode skills unlinked", skills(COMMANDCODE))
4427
+ if "copilot" in hosts:
4428
+ claim("copilot hooks/tezgah.json gone",
4429
+ [os.path.join(COPILOT, "hooks", "tezgah.json")])
4430
+ clean("copilot settings.json statusLine gone",
4431
+ [os.path.join(COPILOT, "settings.json")], needle="tezgah-statusline")
4432
+ clean("copilot copilot-instructions.md block gone",
4433
+ [os.path.join(COPILOT, "copilot-instructions.md")], needle="<!-- tezgah:start")
4434
+ claim("copilot skills unlinked", skills(COPILOT))
4435
+ if "gemini" in hosts:
4436
+ claim("gemini extension dir gone", [GEMINI_EXT])
4437
+ if not gemini_md_kept("gemini"):
4438
+ clean("gemini GEMINI.md block gone", [os.path.join(GEMINI, "GEMINI.md")],
4439
+ needle="<!-- tezgah:start")
4440
+ if "antigravity" in hosts:
4441
+ clean("antigravity config/hooks.json unwired", [AGY_HOOKS],
4442
+ needle="tezgah-antigravity-hook")
4443
+ clean("antigravity settings.json statusLine gone",
4444
+ [os.path.join(ANTIGRAVITY, "settings.json")], needle="tezgah-statusline")
4445
+ claim("antigravity skills unlinked", skills(ANTIGRAVITY))
4446
+ if not gemini_md_kept("antigravity"):
4447
+ clean("antigravity GEMINI.md block gone", [STATIC_RULES["antigravity"]()],
4448
+ needle="<!-- tezgah:start")
4449
+ if "goose" in hosts:
4450
+ claim("goose plugin dir gone", [GOOSE_PLUGIN])
4451
+ clean("goose .goosehints block gone", [STATIC_RULES["goose"]()],
4452
+ needle="<!-- tezgah:start")
4453
+ if "amp" in hosts:
4454
+ claim("amp plugin gone", [AMP_PLUGIN] if amp_plugin_ours() else [])
4455
+ claim("amp skills unlinked", skills(AMP))
4456
+ clean("amp AGENTS.md block gone", [STATIC_RULES["amp"]()],
4457
+ needle="<!-- tezgah:start")
3469
4458
  if "claude" in hosts:
3470
4459
  clean("claude settings.json unwired",
3471
- [os.path.join(CLAUDE, "settings.json")])
4460
+ [os.path.join(CLAUDE, "settings.json")], needle=farm_mark + ('"tezgah@',))
3472
4461
  claim("legacy kill switches gone",
3473
4462
  [os.path.join(HOME, ".claude", n) for n in OFF_SWITCH_NAMES
3474
4463
  if os.path.exists(os.path.join(HOME, ".claude", n))])
@@ -3482,16 +4471,16 @@ def verify_uninstall(hosts, full):
3482
4471
 
3483
4472
 
3484
4473
  def unwire_claude_settings():
3485
- """Remove tezgah's statusLine, attribution and enabledPlugins keys, leaving
3486
- everything else."""
4474
+ """Take back tezgah's statusLine (the user's own comes back from the
4475
+ install-prior record), attribution and enabledPlugins rows, leaving
4476
+ everything else; an `enabledPlugins` left empty goes too."""
3487
4477
  sp = os.path.join(CLAUDE, "settings.json")
3488
4478
  s = read_json(sp, None)
3489
- if not s:
4479
+ if not s or not isinstance(s, dict):
3490
4480
  return
3491
4481
  changed = False
3492
4482
  if "statusline.py" in json.dumps(s.get("statusLine") or {}):
3493
- s.pop("statusLine", None)
3494
- changed = True
4483
+ changed = restore_prior(sp, s, "statusLine")
3495
4484
  if s.get("attribution") == {"commit": "", "pr": "", "sessionUrl": False}:
3496
4485
  s.pop("attribution", None)
3497
4486
  changed = True
@@ -3500,9 +4489,12 @@ def unwire_claude_settings():
3500
4489
  if isinstance(enabled, dict) else []:
3501
4490
  del enabled[key]
3502
4491
  changed = True
4492
+ if not enabled:
4493
+ s.pop("enabledPlugins")
3503
4494
  if changed:
3504
4495
  write_json(sp, s)
3505
4496
  print("%s removed tezgah statusLine/attribution/enabledPlugins from %s (restore from its .tezgah-bak copies under %s if wanted)" % (OK, sp, BACKUP_DIR))
4497
+ forget_prior(sp)
3506
4498
 
3507
4499
 
3508
4500
  def unwire_opencode_config():
@@ -3541,8 +4533,9 @@ def unwire_opencode_config():
3541
4533
  changed = True
3542
4534
  comp = c.get("compaction")
3543
4535
  if isinstance(comp, dict) and comp.get("prune") is True:
3544
- c.pop("compaction", None)
3545
- changed = True
4536
+ # only the `prune` install added, and `compaction` only when the
4537
+ # install created it and nothing else is left in it
4538
+ changed = restore_prior(p, c, "compaction/prune") or changed
3546
4539
  wat = c.get("watcher")
3547
4540
  if isinstance(wat, dict) and isinstance(wat.get("ignore"), list):
3548
4541
  kept = [p for p in wat["ignore"] if p not in OPENCODE_IGNORE]
@@ -3565,6 +4558,7 @@ def unwire_opencode_config():
3565
4558
  if changed:
3566
4559
  write_json(p, c)
3567
4560
  print("%s removed tezgah instructions/skill-deny from %s" % (OK, p))
4561
+ forget_prior(p)
3568
4562
  t = os.path.join(OPENCODE, "tui.json")
3569
4563
  tj = read_json(t, None)
3570
4564
  if tj and tj.get("plugin"):
@@ -3577,6 +4571,7 @@ def unwire_opencode_config():
3577
4571
  tj.pop("plugin", None)
3578
4572
  write_json(t, tj)
3579
4573
  print("%s removed tezgah TUI plugin from %s" % (OK, t))
4574
+ drop_if_empty(t, {"$schema": "https://opencode.ai/tui.json"})
3580
4575
  cp = os.path.join(OPENCODE, "cli.json")
3581
4576
  cj = read_json(cp, None)
3582
4577
  if cj and isinstance(cj.get("plugins"), list):
@@ -3585,6 +4580,7 @@ def unwire_opencode_config():
3585
4580
  cj["plugins"] = kept
3586
4581
  write_json(cp, cj)
3587
4582
  print("%s removed tezgah CLI plugin from %s" % (OK, cp))
4583
+ forget_prior(cp)
3588
4584
 
3589
4585
 
3590
4586
  def uninstall_claude():
@@ -3631,6 +4627,7 @@ def uninstall_codex():
3631
4627
  for s in SKILLS:
3632
4628
  unlink_tezgah(os.path.join(CODEX, "skills", s))
3633
4629
  drop_managed_hook_entries(os.path.join(CODEX, "hooks.json"))
4630
+ drop_if_empty(os.path.join(CODEX, "hooks.json"))
3634
4631
  strip_toml_mcp(os.path.join(CODEX, "config.toml"),
3635
4632
  app_mcp_names() + [TEZGAH_MCP_NAME])
3636
4633
  unlink_tezgah(os.path.join(CODEX, "bin", "consult"))
@@ -3662,6 +4659,7 @@ def uninstall_cursor():
3662
4659
  for s in SKILLS:
3663
4660
  unlink_tezgah(os.path.join(CURSOR, "skills", s))
3664
4661
  drop_managed_hook_entries(os.path.join(CURSOR, "hooks.json"))
4662
+ drop_if_empty(os.path.join(CURSOR, "hooks.json"), {"version": 1})
3665
4663
  mpath = os.path.join(CURSOR, "mcp.json")
3666
4664
  mcfg = read_json(mpath, None)
3667
4665
  if mcfg and isinstance(mcfg.get("mcpServers"), dict):
@@ -3676,9 +4674,255 @@ def uninstall_cursor():
3676
4674
  cfg = read_json(cc, None)
3677
4675
  if isinstance(cfg, dict) and "tezgah-statusline" in json.dumps(
3678
4676
  cfg.get("statusLine") or {}):
3679
- cfg.pop("statusLine", None)
4677
+ # the user's own statusLine comes back, and `version` goes when the
4678
+ # install added it to a file that had none
4679
+ restore_prior(cc, cfg, "statusLine")
4680
+ restore_prior(cc, cfg, "version", keep_unrecorded=True)
3680
4681
  write_json(cc, cfg)
3681
4682
  print("%s removed tezgah statusLine from %s" % (OK, cc))
4683
+ drop_if_empty(cc, {"version": 1})
4684
+
4685
+
4686
+ def uninstall_qwen():
4687
+ print("\nqwen:")
4688
+ for s in SKILLS:
4689
+ unlink_tezgah(os.path.join(QWEN, "skills", s))
4690
+ path = os.path.join(QWEN, "settings.json")
4691
+ drop_managed_hook_entries(path)
4692
+ cfg = read_json(path, None)
4693
+ ui = cfg.get("ui") if isinstance(cfg, dict) else None
4694
+ if isinstance(ui, dict) and "tezgah-statusline" in json.dumps(ui.get("statusLine") or {}):
4695
+ restore_prior(path, cfg, "ui/statusLine")
4696
+ write_json(path, cfg)
4697
+ print("%s removed tezgah statusLine from %s" % (OK, path))
4698
+ drop_if_empty(path)
4699
+ strip_managed_md(os.path.join(QWEN, "QWEN.md"))
4700
+
4701
+
4702
+ def drop_if_empty(path, shell=None):
4703
+ """Remove a host file tezgah created (the install-prior record says so)
4704
+ once uninstall emptied it: `{}`, or the bare `shell` its installer seeds a
4705
+ new file with. A file the user had before the install stays, `{}` or not;
4706
+ one an install older than the record wrote goes when emptied, as it always
4707
+ did. The record of the file goes once its uninstall is done - not while
4708
+ the file cannot be read: the refusal asks for a re-run, which still needs
4709
+ the user's prior values."""
4710
+ try:
4711
+ with open(path, encoding="utf-8") as fh:
4712
+ fh.read()
4713
+ except FileNotFoundError:
4714
+ pass
4715
+ except (OSError, ValueError): # unreadable, or not UTF-8
4716
+ return
4717
+ data = read_json(path, None)
4718
+ if (data == {} or (shell is not None and data == shell)) \
4719
+ and prior_created(path) is not False:
4720
+ backup(path)
4721
+ os.remove(path)
4722
+ print("%s removed %s (empty once tezgah's keys left)" % (OK, path))
4723
+ forget_prior(path)
4724
+
4725
+
4726
+ def uninstall_droid():
4727
+ print("\ndroid:")
4728
+ for s in SKILLS:
4729
+ unlink_tezgah(os.path.join(DROID, "skills", s))
4730
+ path = os.path.join(DROID, "hooks.json")
4731
+ data = read_json(path, None)
4732
+ if isinstance(data, dict):
4733
+ kept = {}
4734
+ for ev, arr in data.items():
4735
+ if isinstance(arr, list):
4736
+ arr = [e for e in arr if "tezgah" not in json.dumps(e)]
4737
+ if not arr:
4738
+ continue
4739
+ kept[ev] = arr
4740
+ if kept == data:
4741
+ print("%s no tezgah hook entries in %s" % (OK, path))
4742
+ elif kept:
4743
+ write_json(path, kept)
4744
+ print("%s removed tezgah hook entries from %s" % (OK, path))
4745
+ else:
4746
+ # an empty hooks.json would still hide the `hooks` key of settings.json
4747
+ backup(path)
4748
+ os.remove(path)
4749
+ print("%s removed %s (it held only tezgah's hooks)" % (OK, path))
4750
+ spath = os.path.join(DROID, "settings.json")
4751
+ cfg = read_json(spath, None)
4752
+ key = "includeCoAuthoredByDroid"
4753
+ if isinstance(cfg, dict):
4754
+ changed = False
4755
+ if "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
4756
+ cfg.pop("statusLine")
4757
+ changed = True
4758
+ # the trailer switch goes back to what the install found (OQ-15); an
4759
+ # install older than the record leaves it false, as it always did
4760
+ if cfg.get(key) is False:
4761
+ changed = restore_prior(spath, cfg, key, keep_unrecorded=True) or changed
4762
+ if changed:
4763
+ write_json(spath, cfg)
4764
+ print("%s removed tezgah statusLine from %s" % (OK, spath))
4765
+ elif cfg is None and os.path.exists(spath):
4766
+ # JSON with comments: take out the lines install_droid put in, keep
4767
+ # the comments
4768
+ try:
4769
+ with open(spath, encoding="utf-8") as fh:
4770
+ text = fh.read()
4771
+ except (OSError, ValueError) as exc: # unreadable, or not UTF-8
4772
+ refuse(spath, read_failure(exc), "--uninstall")
4773
+ text = ""
4774
+ found = jsonc_loads(text) or {}
4775
+ old = found.get("statusLine")
4776
+ new = jsonc_drop(text, "statusLine", old) if "tezgah-statusline" in json.dumps(old or "") else None
4777
+ if found.get(key) is False:
4778
+ want = dict(found)
4779
+ restore_prior(spath, want, key, keep_unrecorded=True)
4780
+ cur = text if new is None else new
4781
+ back = (jsonc_drop(cur, key, False) if key not in want
4782
+ else jsonc_put(cur, key, want[key]) if want[key] is not False else None)
4783
+ new = back if back is not None else new
4784
+ if new is not None:
4785
+ write_text(spath, new)
4786
+ print("%s removed tezgah statusLine from %s" % (OK, spath))
4787
+ drop_if_empty(spath)
4788
+ strip_managed_md(os.path.join(DROID, "AGENTS.md"))
4789
+
4790
+
4791
+ def uninstall_commandcode():
4792
+ print("\ncommandcode:")
4793
+ for s in SKILLS:
4794
+ unlink_tezgah(os.path.join(COMMANDCODE, "skills", s))
4795
+ path = os.path.join(COMMANDCODE, "settings.json")
4796
+ drop_managed_hook_entries(path)
4797
+ cfg = read_json(path, None)
4798
+ attr = cfg.get("attribution") if isinstance(cfg, dict) else None
4799
+ # the value install wrote goes; a value the user had before the install is
4800
+ # put back from the install-prior record (OQ-15)
4801
+ if isinstance(attr, dict) and attr.get("commit") == "":
4802
+ restore_prior(path, cfg, "attribution/commit")
4803
+ write_json(path, cfg)
4804
+ print("%s removed tezgah's attribution.commit from %s" % (OK, path))
4805
+ drop_if_empty(path)
4806
+ strip_managed_md(os.path.join(COMMANDCODE, "AGENTS.md"))
4807
+
4808
+
4809
+ def uninstall_copilot():
4810
+ print("\ncopilot:")
4811
+ for s in SKILLS:
4812
+ unlink_tezgah(os.path.join(COPILOT, "skills", s))
4813
+ path = os.path.join(COPILOT, "hooks", "tezgah.json")
4814
+ if os.path.exists(path):
4815
+ backup(path)
4816
+ os.remove(path)
4817
+ print("%s removed %s" % (OK, path))
4818
+ spath = os.path.join(COPILOT, "settings.json")
4819
+ cfg = read_json(spath, None)
4820
+ if isinstance(cfg, dict):
4821
+ if "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
4822
+ restore_prior(spath, cfg, "statusLine")
4823
+ # the value install wrote goes; one the user had before the install
4824
+ # comes back from the install-prior record (OQ-15)
4825
+ if cfg.get("includeCoAuthoredBy") is False:
4826
+ restore_prior(spath, cfg, "includeCoAuthoredBy")
4827
+ if write_json(spath, cfg):
4828
+ print("%s removed tezgah's statusLine and includeCoAuthoredBy from %s"
4829
+ % (OK, spath))
4830
+ drop_if_empty(spath)
4831
+ strip_managed_md(os.path.join(COPILOT, "copilot-instructions.md"))
4832
+
4833
+
4834
+ def uninstall_gemini():
4835
+ """Delete tezgah's extension dir (its skills are links, unlinked, never
4836
+ followed) and the GEMINI.md block unless Antigravity still reads it."""
4837
+ print("\ngemini:")
4838
+ if os.path.lexists(GEMINI_EXT):
4839
+ for name in ("gemini-extension.json", os.path.join("hooks", "hooks.json")):
4840
+ backup(os.path.join(GEMINI_EXT, name))
4841
+ shutil.rmtree(GEMINI_EXT)
4842
+ print("%s removed %s" % (OK, GEMINI_EXT))
4843
+ if gemini_md_kept("gemini"):
4844
+ print("%s %s keeps the tezgah block: another armed host reads it"
4845
+ % (OK, os.path.join(GEMINI, "GEMINI.md")))
4846
+ else:
4847
+ strip_managed_md(os.path.join(GEMINI, "GEMINI.md"))
4848
+
4849
+
4850
+ def uninstall_antigravity():
4851
+ """Take the `tezgah` name out of ~/.gemini/config/hooks.json (the user's
4852
+ other names stay), the statusLine, the skill links, and the GEMINI.md block
4853
+ unless Gemini CLI still reads it."""
4854
+ print("\nantigravity:")
4855
+ for s in SKILLS:
4856
+ unlink_tezgah(os.path.join(ANTIGRAVITY, "skills", s))
4857
+ data = read_json(AGY_HOOKS, None)
4858
+ if isinstance(data, dict) and "tezgah" in data:
4859
+ data.pop("tezgah")
4860
+ write_json(AGY_HOOKS, data)
4861
+ print("%s removed tezgah's hooks from %s" % (OK, AGY_HOOKS))
4862
+ drop_if_empty(AGY_HOOKS)
4863
+ spath = os.path.join(ANTIGRAVITY, "settings.json")
4864
+ cfg = read_json(spath, None)
4865
+ if isinstance(cfg, dict) and "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
4866
+ restore_prior(spath, cfg, "statusLine")
4867
+ write_json(spath, cfg)
4868
+ print("%s removed tezgah's statusLine from %s" % (OK, spath))
4869
+ drop_if_empty(spath)
4870
+ rules = STATIC_RULES["antigravity"]()
4871
+ if gemini_md_kept("antigravity"):
4872
+ print("%s %s keeps the tezgah block: another armed host reads it" % (OK, rules))
4873
+ else:
4874
+ strip_managed_md(rules)
4875
+
4876
+
4877
+ def uninstall_goose():
4878
+ """Delete tezgah's plugin dir (its skills are links, unlinked, never
4879
+ followed) and the .goosehints block; the rest of ~/.agents is the user's."""
4880
+ print("\ngoose:")
4881
+ if os.path.lexists(GOOSE_PLUGIN):
4882
+ for name in ("plugin.json", os.path.join("hooks", "hooks.json")):
4883
+ backup(os.path.join(GOOSE_PLUGIN, name))
4884
+ shutil.rmtree(GOOSE_PLUGIN)
4885
+ print("%s removed %s" % (OK, GOOSE_PLUGIN))
4886
+ strip_managed_md(STATIC_RULES["goose"]())
4887
+
4888
+
4889
+ def amp_plugin_ours():
4890
+ """True when the file at AMP_PLUGIN is tezgah's: a fresh render, or one an
4891
+ older tree rendered (it starts with AMP_HEADER). A user's own plugin of
4892
+ that name is never removed."""
4893
+ try:
4894
+ with open(AMP_PLUGIN, encoding="utf-8") as fh:
4895
+ text = fh.read()
4896
+ except OSError:
4897
+ return False
4898
+ return text == ta.amp_plugin(HERE) or text.startswith(ta.AMP_HEADER)
4899
+
4900
+
4901
+ def uninstall_amp():
4902
+ """Delete tezgah's plugin file (only when it is tezgah's), the skill links,
4903
+ the AGENTS.md block, and each commit-trailer key while it is still false."""
4904
+ print("\namp:")
4905
+ if amp_plugin_ours():
4906
+ backup(AMP_PLUGIN)
4907
+ os.remove(AMP_PLUGIN)
4908
+ print("%s removed %s" % (OK, AMP_PLUGIN))
4909
+ elif os.path.lexists(AMP_PLUGIN):
4910
+ print("%s %s is not tezgah's render: left in place" % (NO, AMP_PLUGIN))
4911
+ for s in SKILLS:
4912
+ unlink_tezgah(os.path.join(AMP, "skills", s))
4913
+ spath, settings = amp_settings("--uninstall")
4914
+ if isinstance(settings, dict) and any(settings.get(k) is False for k in AMP_ATTRIBUTION):
4915
+ for key in AMP_ATTRIBUTION:
4916
+ if settings.get(key) is False:
4917
+ # a value the user had before the install comes back (OQ-15)
4918
+ restore_prior(spath, settings, key)
4919
+ write_json(spath, settings)
4920
+ print("%s removed tezgah's commit-trailer keys from %s" % (OK, spath))
4921
+ # the record goes on every uninstall that read the file: one left behind
4922
+ # would hand a later install's false back as the user's prior value
4923
+ if settings is not None:
4924
+ drop_if_empty(spath)
4925
+ strip_managed_md(STATIC_RULES["amp"]())
3682
4926
 
3683
4927
 
3684
4928
  def uninstall_dsh():
@@ -3714,8 +4958,15 @@ def uninstall_omp():
3714
4958
  if isinstance(cfg, dict):
3715
4959
  servers = cfg.get("mcpServers")
3716
4960
  if isinstance(servers, dict):
3717
- for name in ["codegraph", TEZGAH_MCP_NAME] + app_mcp_names():
4961
+ for name in [TEZGAH_MCP_NAME] + app_mcp_names():
3718
4962
  servers.pop(name, None)
4963
+ # codegraph goes only as the row install_omp writes: a row of the
4964
+ # user's own (another binary, other args) is theirs, as on the
4965
+ # other hosts
4966
+ argv = graph_command()
4967
+ if servers.get("codegraph") == {"type": "stdio", "command": argv[0],
4968
+ "args": argv[1:], "env": dict(GRAPH_ENV)}:
4969
+ servers.pop("codegraph")
3719
4970
  if not servers:
3720
4971
  cfg.pop("mcpServers", None)
3721
4972
  # $schema was written by install_omp and nothing else: a file left
@@ -3731,9 +4982,41 @@ def uninstall_omp():
3731
4982
  strip_managed_md(os.path.join(OMP_AGENT, "RULES.md"))
3732
4983
 
3733
4984
 
4985
+ def _under(key, hosts):
4986
+ """True when the recorded dir `key` ("<dir>/") lies in one of `hosts`'
4987
+ own directories."""
4988
+ roots = [tp.HOST_DIRS[h] for h in hosts if h in tp.HOST_DIRS]
4989
+ roots += [os.path.dirname(AGY_HOOKS)] if "antigravity" in hosts else []
4990
+ roots += [os.path.dirname(GOOSE_PLUGIN)] if "goose" in hosts else []
4991
+ return any(key.startswith(r + os.sep) for r in roots)
4992
+
4993
+
4994
+ def remove_created_dirs(created):
4995
+ """Remove each directory in `created` (the record's dirs the install made,
4996
+ deepest first) that uninstall left empty. A directory the user made, or one
4997
+ holding anything, stays; the record forgets each dir that is gone (OQ-13)."""
4998
+ gone = []
4999
+ for key in created:
5000
+ d = key[:-1]
5001
+ if os.path.isdir(d) and not os.path.islink(d) and not os.listdir(d):
5002
+ os.rmdir(d)
5003
+ print("%s removed %s (empty)" % (OK, d))
5004
+ if not os.path.lexists(d):
5005
+ gone.append(key)
5006
+ if gone and os.path.exists(PRIOR): # a full run already took the record
5007
+ store = read_json(PRIOR, {})
5008
+ for key in gone:
5009
+ store.pop(key, None)
5010
+ _prior_save(store)
5011
+
5012
+
3734
5013
  UNINSTALLERS = {"claude": uninstall_claude, "codex": uninstall_codex,
3735
5014
  "opencode": uninstall_opencode, "cursor": uninstall_cursor,
3736
- "dsh": uninstall_dsh, "omp": uninstall_omp}
5015
+ "dsh": uninstall_dsh, "omp": uninstall_omp, "qwen": uninstall_qwen,
5016
+ "droid": uninstall_droid, "commandcode": uninstall_commandcode,
5017
+ "copilot": uninstall_copilot, "gemini": uninstall_gemini,
5018
+ "antigravity": uninstall_antigravity, "goose": uninstall_goose,
5019
+ "amp": uninstall_amp}
3737
5020
 
3738
5021
 
3739
5022
  def uninstall(hosts, dry_run=False):
@@ -3755,6 +5038,7 @@ def uninstall(hosts, dry_run=False):
3755
5038
  repository's `.tezgah/` research state. A partial run (some armed hosts
3756
5039
  stay) also keeps config.json and the install tree, because the hosts still
3757
5040
  armed read both at runtime."""
5041
+ global UNINSTALLING
3758
5042
  armed = tp.config().get("hosts") or []
3759
5043
  full = not armed or set(hosts) >= set(armed)
3760
5044
  if dry_run:
@@ -3786,12 +5070,33 @@ def uninstall(hosts, dry_run=False):
3786
5070
  print("%s gate stays armed for the hosts this run does not touch" % OK)
3787
5071
  print("uninstalling for: %s (%s run)\n"
3788
5072
  % (", ".join(hosts) or "none detected", "full" if full else "partial"))
5073
+ UNINSTALLING = tuple(hosts)
5074
+ print("\ncommon:")
5075
+ if full:
5076
+ unlink_dir(tp.BIN_DIR)
5077
+ else:
5078
+ # the farm is shared: every host still armed runs its hook, the gate and
5079
+ # the status line from it, so a partial run takes only the hook links of
5080
+ # the hosts it removes (B-007: emptying it disarmed the hosts that stay)
5081
+ for h in hosts:
5082
+ unlink_tezgah(os.path.join(tp.BIN_DIR, "tezgah-%s-hook" % h))
3789
5083
  uninstall_common()
3790
5084
  for h in hosts:
3791
5085
  UNINSTALLERS[h]()
3792
5086
  if "codex" in hosts and "cursor" in (armed or []) and "cursor" not in hosts:
3793
5087
  print("\ncursor (its skills came through ~/.codex/skills):")
3794
5088
  link_cursor_skills()
5089
+ # every directory the install created goes once empty, deepest first; the
5090
+ # list is read now, because a full run takes the record with the config
5091
+ created = sorted((k for k, v in read_json(PRIOR, {}).items()
5092
+ if k.endswith(os.sep) and isinstance(v, dict)
5093
+ and v.get("created") is True), key=len, reverse=True)
5094
+ if not full:
5095
+ # a partial run takes only dirs under the removed hosts' own roots; a
5096
+ # shared one (the caches, ~/.config) or an armed host's stays recorded
5097
+ # until the full run
5098
+ rest = set(armed) - set(hosts)
5099
+ created = [k for k in created if _under(k, hosts) and not _under(k, rest)]
3795
5100
  if "claude" in hosts:
3796
5101
  print("\nclaude plugin copy:")
3797
5102
  remove_plugin_copies()
@@ -3810,6 +5115,7 @@ def uninstall(hosts, dry_run=False):
3810
5115
  write_json(tp.CONFIG, cfg)
3811
5116
  print("\n%s still armed: %s keeps its config and the install tree"
3812
5117
  % (NO, ", ".join(rest)))
5118
+ remove_created_dirs(created)
3813
5119
  remaining = verify_uninstall(hosts, full)
3814
5120
  print("\nrestart each host so in-memory wiring unloads.")
3815
5121
  pred = predecessors()
@@ -4128,12 +5434,13 @@ def context_budget_report():
4128
5434
 
4129
5435
  LIVE_TIMEOUT = 20
4130
5436
  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).
5437
+ # The JS half: loads the omp extension or the Amp plugin
5438
+ # (`--experimental-strip-types`, the .ts as their bun would) or the opencode
5439
+ # plugin (an ES module, as opencode imports it), registers its handlers against
5440
+ # inert host objects and fires one handler: the tool-result one, or with a
5441
+ # command the tool-call one, printing what it answered (omp, Amp) or what it
5442
+ # threw (opencode) as JSON. argv: host, module path, root, session id, command
5443
+ # ("" for the tool result).
4137
5444
  LIVE_DRIVER = r"""
4138
5445
  const [host, mod, root, sid, cmd] = process.argv.slice(1);
4139
5446
  const noop = new Proxy(function () {}, { get: () => noop, apply: () => noop });
@@ -4154,6 +5461,25 @@ const noop = new Proxy(function () {}, { get: () => noop, apply: () => noop });
4154
5461
  }
4155
5462
  await handlers.tool_result({ toolName: "bash", input: { command: "true" },
4156
5463
  isError: false, content: "ok" }, ctx);
5464
+ } else if (host === "amp") {
5465
+ // Amp's registry, its two helpers the plugin reads, and a thread context;
5466
+ // no status item (`experimental` absent), so no status spawn either
5467
+ const handlers = {};
5468
+ m.default(new Proxy({ on: (n, fn) => { handlers[n] = fn; }, experimental: undefined,
5469
+ system: { workspaceRoot: root },
5470
+ helpers: { filePathFromURI: (u) => String(u),
5471
+ shellCommandFromToolCall: (e) => ({ command: e.input.cmd }) } },
5472
+ { get: (t, k) => (k in t ? t[k] : noop) }));
5473
+ const want = cmd ? "tool.call" : "tool.result";
5474
+ if (!handlers[want]) throw new Error("the plugin registered no " + want + " handler");
5475
+ const ctx = { thread: { id: sid, parentThreadID: async () => null }, ui: noop };
5476
+ const event = { tool: "Bash", input: { cmd: cmd || "true" }, toolUseID: "live",
5477
+ thread: { id: sid } };
5478
+ if (cmd) {
5479
+ process.stdout.write(JSON.stringify((await handlers[want](event, ctx)) || {}));
5480
+ return;
5481
+ }
5482
+ await handlers[want](Object.assign(event, { status: "done", output: "ok" }), ctx);
4157
5483
  } else {
4158
5484
  const hooks = await (m.Tezgah || m.default)({ directory: root, worktree: root,
4159
5485
  client: noop, $: noop });
@@ -4316,6 +5642,123 @@ def live_plan(host, deny=False):
4316
5642
  if registered is None:
4317
5643
  return plan + ("registration unread: the omp CLI did not answer",)
4318
5644
  return plan
5645
+ if host == "qwen":
5646
+ # the hook command Qwen runs for its shell tool, fed Qwen's own payload;
5647
+ # no qwen binary and no model call (tests/e2e_qwen.py drives the binary)
5648
+ path = os.path.join(QWEN, "settings.json")
5649
+ cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event),
5650
+ "run_shell_command")
5651
+ if not cmd:
5652
+ return ("miss", "no tezgah %s command for run_shell_command in %s"
5653
+ % (event, path))
5654
+ payload = {"hook_event_name": event, "tool_name": "run_shell_command",
5655
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
5656
+ if not deny:
5657
+ payload["tool_response"] = {"llmContent": "", "returnDisplay": ""}
5658
+ return ("shell", cmd, payload, {})
5659
+ if host == "droid":
5660
+ # the hook command Droid runs for Execute, fed Droid's own payload; no
5661
+ # droid binary and no model call (tests/e2e_droid.py drives the binary)
5662
+ path = os.path.join(DROID, "hooks.json")
5663
+ cmd = _hook_command(read_json(path, {}).get(event), "Execute")
5664
+ if not cmd:
5665
+ return ("miss", "no tezgah %s command for Execute in %s" % (event, path))
5666
+ payload = {"hook_event_name": event, "tool_name": "Execute",
5667
+ "permission_mode": "auto-low",
5668
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
5669
+ if not deny:
5670
+ payload["tool_response"] = "\n\n[Process exited with code 0]"
5671
+ return ("shell", cmd, payload, {})
5672
+ if host == "commandcode":
5673
+ # the hook command Command Code runs for its shell tool (the matcher sees
5674
+ # the display name SHELL, case-insensitively), fed its own payload; no
5675
+ # binary and no model call
5676
+ path = os.path.join(COMMANDCODE, "settings.json")
5677
+ cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event), "shell")
5678
+ if not cmd:
5679
+ return ("miss", "no tezgah %s command for shell in %s" % (event, path))
5680
+ payload = {"hook_event_name": event, "tool_name": "shell_command",
5681
+ "tool_display_name": "SHELL", "permission_mode": "default",
5682
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
5683
+ if not deny:
5684
+ payload["tool_response"] = ""
5685
+ return ("shell", cmd, payload, {})
5686
+ if host == "copilot":
5687
+ # the hook command Copilot runs for its shell tool (a PascalCase
5688
+ # PreToolUse sees the Claude name Bash), fed its own payload; no binary
5689
+ # and no model call
5690
+ path = os.path.join(COPILOT, "hooks", "tezgah.json")
5691
+ cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event), "Bash",
5692
+ own=True)
5693
+ if not cmd:
5694
+ return ("miss", "no tezgah %s command for Bash in %s" % (event, path))
5695
+ payload = {"hook_event_name": event, "tool_name": "Bash",
5696
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
5697
+ if not deny:
5698
+ payload["tool_result"] = {"result_type": "success",
5699
+ "text_result_for_llm": "<exited with exit code 0>"}
5700
+ return ("shell", cmd, payload, {})
5701
+ if host == "gemini":
5702
+ # the hook command Gemini runs for its shell tool (BeforeTool/AfterTool),
5703
+ # fed Gemini's own payload; no binary and no model call
5704
+ path = os.path.join(GEMINI_EXT, "hooks", "hooks.json")
5705
+ name = "BeforeTool" if deny else "AfterTool"
5706
+ cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(name),
5707
+ "run_shell_command", own=True)
5708
+ if not cmd:
5709
+ return ("miss", "no tezgah %s command for run_shell_command in %s"
5710
+ % (name, path))
5711
+ payload = {"hook_event_name": name, "tool_name": "run_shell_command",
5712
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
5713
+ if not deny:
5714
+ payload["tool_response"] = {"llmContent": "Output: (empty)",
5715
+ "returnDisplay": ""}
5716
+ return ("shell", cmd, payload, {})
5717
+ if host == "antigravity":
5718
+ # the hook command Antigravity runs for run_command, under the `tezgah`
5719
+ # name of ~/.gemini/config/hooks.json; it carries the event as argv[1],
5720
+ # since the camelCase payload names none. No binary and no model call
5721
+ cmd = _hook_command((read_json(AGY_HOOKS, {}).get("tezgah") or {}).get(event),
5722
+ "run_command", own=True)
5723
+ if not cmd:
5724
+ return ("miss", "no tezgah %s command for run_command in %s" % (event, AGY_HOOKS))
5725
+ payload = {"toolCall": {"name": "run_command", "args": {
5726
+ "CommandLine": LIVE_DENY_COMMAND if deny else "true", "WaitMsBeforeAsync": 5000}},
5727
+ "stepIdx": 0}
5728
+ if not deny:
5729
+ payload["error"] = ""
5730
+ return ("shell", cmd, payload, {})
5731
+ if host == "goose":
5732
+ # the hook command Goose runs for its `shell` tool, from tezgah's plugin
5733
+ # hooks.json, fed Goose's own payload (the event in `event`, the
5734
+ # directory as `working_dir`); no binary and no model call
5735
+ path = os.path.join(GOOSE_PLUGIN, "hooks", "hooks.json")
5736
+ cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event), "shell",
5737
+ own=True)
5738
+ if not cmd:
5739
+ return ("miss", "no tezgah %s command for shell in %s" % (event, path))
5740
+ return ("shell", cmd, {"event": event, "tool_name": "shell",
5741
+ "matcher_context": "shell", "tool_call_id": "tezgah-live",
5742
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny
5743
+ else "true"}}, {})
5744
+ if host == "amp":
5745
+ # the installed plugin itself, loaded by node through LIVE_DRIVER and fed
5746
+ # Amp's own event shape (`tool`, `input.cmd`, `thread.id`); the hook it
5747
+ # spawns is the one Amp would spawn
5748
+ try:
5749
+ with open(AMP_PLUGIN, encoding="utf-8") as fh:
5750
+ text = fh.read()
5751
+ except OSError:
5752
+ return ("miss", "no plugin at %s" % AMP_PLUGIN)
5753
+ if text != ta.amp_plugin(HERE):
5754
+ return ("miss", "%s differs from a fresh render of this tree's plugin "
5755
+ "- re-run `tezgah-setup --install --hosts amp`" % AMP_PLUGIN)
5756
+ node = _node_for_ts()
5757
+ if not node:
5758
+ return ("unverified", "no node 22.6+ to load %s" % AMP_PLUGIN)
5759
+ # always unverified: Amp's own Bun loader is not what ran it
5760
+ return ("node", [node, "--experimental-strip-types", "--no-warnings"], AMP_PLUGIN,
5761
+ "Amp's own plugin loader is not driven here")
4319
5762
  return ("unverified", "no live probe for this host")
4320
5763
 
4321
5764
 
@@ -4333,6 +5776,21 @@ def _denied(host, stdout):
4333
5776
  return got.get("block") is True
4334
5777
  if host == "opencode":
4335
5778
  return bool(got.get("threw"))
5779
+ if host == "copilot":
5780
+ # top level, not wrapped (hooks-reference "preToolUse decision control")
5781
+ return got.get("permissionDecision") == "deny"
5782
+ if host == "gemini":
5783
+ # top level (hooks reference "BeforeTool"; `block` is its alias)
5784
+ return got.get("decision") in ("deny", "block")
5785
+ if host == "antigravity":
5786
+ # top level; `decision` is required, so an allow answers too (docs/hooks)
5787
+ return got.get("decision") == "deny"
5788
+ if host == "goose":
5789
+ # top level, stdout starting with `{` (hooks "Blocking a Tool Call")
5790
+ return got.get("decision") == "block"
5791
+ if host == "amp":
5792
+ # ToolCallResult (plugin-api): the call is refused, the turn goes on
5793
+ return got.get("action") == "reject-and-continue"
4336
5794
  return (got.get("hookSpecificOutput") or {}).get("permissionDecision") == "deny"
4337
5795
 
4338
5796
 
@@ -4365,7 +5823,13 @@ def live_probe(host, deny=False):
4365
5823
  if plan[0] == "shell":
4366
5824
  _kind, cmd, payload, extra = plan
4367
5825
  env.update(extra)
4368
- payload = dict(payload, session_id=sid, conversation_id=sid, cwd=root)
5826
+ # Antigravity's envelope is camelCase, with no session_id or cwd;
5827
+ # Goose names the directory `working_dir`
5828
+ payload = (dict(payload, conversationId=sid, workspacePaths=[root])
5829
+ if host == "antigravity" else
5830
+ dict(payload, session_id=sid, working_dir=root)
5831
+ if host == "goose" else
5832
+ dict(payload, session_id=sid, conversation_id=sid, cwd=root))
4369
5833
  proc = subprocess.run(cmd, shell=True, input=json.dumps(payload),
4370
5834
  capture_output=True, text=True, env=env,
4371
5835
  cwd=root, timeout=LIVE_TIMEOUT)
@@ -4438,7 +5902,9 @@ ARMING_ROWS = ("hooks.json wired", "plugin wired (link or copy)",
4438
5902
  "carries the contract", "contract in instructions",
4439
5903
  "claude-code hooks bridge in cordis.patch.yml",
4440
5904
  "session, prompt, tool and stop hooks wired",
4441
- "hook registered in omp extensions", "plugin copy current")
5905
+ "hook registered in omp extensions", "plugin copy current",
5906
+ "settings.json hooks wired", "hooks/tezgah.json wired",
5907
+ "plugin tezgah.ts current")
4442
5908
 
4443
5909
 
4444
5910
  def arming_miss(label, good):
@@ -4704,14 +6170,17 @@ def claude_mcp_rows(copies):
4704
6170
  return rows
4705
6171
 
4706
6172
 
4707
- def _hooks_has(path, marker, events=None):
6173
+ def _hooks_has(path, marker, events=None, bare=False, key="hooks"):
4708
6174
  """True when `marker` is wired under every event in `events`, or under any
4709
- event when `events` is None.
6175
+ event when `events` is None. `bare`: the file is keyed by event at the top
6176
+ (Droid's hooks.json); `key`: the top-level name the events sit under
6177
+ (`hooks`, or Antigravity's `tezgah`).
4710
6178
 
4711
6179
  A row that one event can satisfy answers the wrong question: a manifest
4712
6180
  carrying tezgah's command under SessionStart and not under Stop reads as
4713
6181
  wired, and Stop is the integrity refusal."""
4714
- hooks = (read_json(path, {}) or {}).get("hooks") or {}
6182
+ data = read_json(path, {}) or {}
6183
+ hooks = data if bare else data.get(key) or {}
4715
6184
  if events is None:
4716
6185
  return any(marker in json.dumps(e) for arr in hooks.values() for e in arr)
4717
6186
  return all(any(marker in json.dumps(e) for e in (hooks.get(ev) or []))
@@ -4850,6 +6319,156 @@ def host_checks_cursor():
4850
6319
  ]
4851
6320
 
4852
6321
 
6322
+ def host_checks_qwen():
6323
+ path = os.path.join(QWEN, "settings.json")
6324
+ # a relocated home (QWEN_HOME) is named, as codex's is
6325
+ where = " in %s" % QWEN if QWEN != os.path.join(tp.HOME, ".qwen") else ""
6326
+ ui = read_json(path, {}).get("ui")
6327
+ return [
6328
+ ("settings.json hooks wired%s" % where,
6329
+ _hooks_has(path, "tezgah-qwen-hook", [ev for ev, _m, _t in QWEN_HOOK_EVENTS])),
6330
+ ("skills linked%s" % where, skills_linked(os.path.join(QWEN, "skills"))),
6331
+ ("QWEN.md carries the contract (matches a fresh render)", rules_current("qwen")),
6332
+ ("statusLine in settings.json ui",
6333
+ "tezgah-statusline" in json.dumps((ui if isinstance(ui, dict) else {})
6334
+ .get("statusLine") or {})),
6335
+ ("qwen CLI on PATH", bool(tp.which_user("qwen"))),
6336
+ ]
6337
+
6338
+
6339
+ def host_checks_droid():
6340
+ path = os.path.join(DROID, "hooks.json")
6341
+ why = droid_hooks_blocked()
6342
+ settings = read_jsonc(os.path.join(DROID, "settings.json")) or {}
6343
+ status = settings.get("statusLine")
6344
+ return [
6345
+ ("hooks.json wired" + (" (not written: %s)" % why if why else ""),
6346
+ _hooks_has(path, "tezgah-droid-hook", [ev for ev, _m, _t in DROID_HOOK_EVENTS],
6347
+ bare=True)),
6348
+ ("skills linked", skills_linked(os.path.join(DROID, "skills"))),
6349
+ ("AGENTS.md carries the contract (matches a fresh render)", rules_current("droid")),
6350
+ ("statusLine in settings.json", "tezgah-statusline" in json.dumps(status or {})),
6351
+ ("includeCoAuthoredByDroid off in settings.json",
6352
+ settings.get("includeCoAuthoredByDroid") is False),
6353
+ ("droid CLI on PATH", bool(tp.which_user("droid"))),
6354
+ ]
6355
+
6356
+
6357
+ def host_checks_commandcode():
6358
+ path = os.path.join(COMMANDCODE, "settings.json")
6359
+ attr = read_json(path, {}).get("attribution")
6360
+ return [
6361
+ ("settings.json hooks wired",
6362
+ _hooks_has(path, "tezgah-commandcode-hook",
6363
+ [ev for ev, _m, _t in COMMANDCODE_HOOK_EVENTS])),
6364
+ ("skills linked", skills_linked(os.path.join(COMMANDCODE, "skills"))),
6365
+ ("AGENTS.md carries the contract (matches a fresh render)",
6366
+ rules_current("commandcode")),
6367
+ ("attribution.commit off in settings.json",
6368
+ isinstance(attr, dict) and attr.get("commit") == ""),
6369
+ ("command-code CLI on PATH", bool(tp.which_user("command-code"))),
6370
+ ]
6371
+
6372
+
6373
+ def host_checks_copilot():
6374
+ # a relocated home (COPILOT_HOME) is named, as codex's is
6375
+ where = " in %s" % COPILOT if COPILOT != os.path.join(tp.HOME, ".copilot") else ""
6376
+ settings = read_jsonc(os.path.join(COPILOT, "settings.json")) or {}
6377
+ return [
6378
+ ("hooks/tezgah.json wired%s" % where,
6379
+ _hooks_has(os.path.join(COPILOT, "hooks", "tezgah.json"), "tezgah-copilot-hook",
6380
+ [ev for ev, _m, _t in COPILOT_HOOK_EVENTS])),
6381
+ ("skills linked%s" % where, skills_linked(os.path.join(COPILOT, "skills"))),
6382
+ ("copilot-instructions.md carries the contract (matches a fresh render)",
6383
+ rules_current("copilot")),
6384
+ ("statusLine in settings.json",
6385
+ "tezgah-statusline" in json.dumps(settings.get("statusLine") or {})),
6386
+ ("includeCoAuthoredBy off in settings.json",
6387
+ settings.get("includeCoAuthoredBy") is False),
6388
+ ("copilot CLI on PATH", bool(tp.which_user("copilot"))),
6389
+ ]
6390
+
6391
+
6392
+ def host_checks_gemini():
6393
+ # a relocated home (GEMINI_CLI_HOME) is named, as codex's is
6394
+ where = " in %s" % GEMINI if GEMINI != os.path.join(tp.HOME, ".gemini") else ""
6395
+ hooks_cfg = (read_jsonc(os.path.join(GEMINI, "settings.json")) or {}).get("hooksConfig")
6396
+ return [
6397
+ # the hooks load only beside a manifest naming the extension
6398
+ ("extension hooks/hooks.json wired%s" % where,
6399
+ _hooks_has(os.path.join(GEMINI_EXT, "hooks", "hooks.json"), "tezgah-gemini-hook",
6400
+ [ev for ev, _m, _t in GEMINI_HOOK_EVENTS])
6401
+ and read_json(os.path.join(GEMINI_EXT, "gemini-extension.json"), {})
6402
+ .get("name") == "tezgah"),
6403
+ ("skills linked%s" % where, skills_linked(os.path.join(GEMINI_EXT, "skills"))),
6404
+ ("GEMINI.md carries the contract (matches a fresh render)", rules_current("gemini")),
6405
+ # `hooksConfig.enabled: false` runs no hook at all (configuration)
6406
+ ("hooks left on in settings.json (hooksConfig.enabled)",
6407
+ not (isinstance(hooks_cfg, dict) and hooks_cfg.get("enabled") is False)),
6408
+ ("gemini CLI on PATH", bool(tp.which_user("gemini"))),
6409
+ ]
6410
+
6411
+
6412
+ def host_checks_antigravity():
6413
+ settings = read_json(os.path.join(ANTIGRAVITY, "settings.json"), {})
6414
+ mine = read_json(AGY_HOOKS, {}).get("tezgah")
6415
+ return [
6416
+ ("config/hooks.json wired (the `tezgah` name)",
6417
+ _hooks_has(AGY_HOOKS, "tezgah-antigravity-hook",
6418
+ [ev for ev, _m, _t in ANTIGRAVITY_HOOK_EVENTS], key="tezgah")),
6419
+ # `enabled: false` on a hook name loads none of its events (docs/hooks)
6420
+ ("tezgah hook set left enabled in config/hooks.json",
6421
+ not (isinstance(mine, dict) and mine.get("enabled") is False)),
6422
+ ("skills linked", skills_linked(os.path.join(ANTIGRAVITY, "skills"))),
6423
+ ("GEMINI.md carries the contract (matches a fresh render)",
6424
+ rules_current("antigravity")),
6425
+ ("statusLine in antigravity-cli/settings.json",
6426
+ "tezgah-statusline" in json.dumps(settings.get("statusLine") or {})),
6427
+ ("agy CLI on PATH", bool(tp.which_user("agy"))),
6428
+ ]
6429
+
6430
+
6431
+ def host_checks_goose():
6432
+ disabled = read_json(os.path.join(GOOSE, "settings.json"), {}).get("disabledPlugins")
6433
+ return [
6434
+ # the hooks load only from a plugin dir under ~/.agents/plugins
6435
+ ("plugin hooks/hooks.json wired (~/.agents/plugins/tezgah)",
6436
+ _hooks_has(os.path.join(GOOSE_PLUGIN, "hooks", "hooks.json"), "tezgah-goose-hook",
6437
+ [ev for ev, _m, _t in GOOSE_HOOK_EVENTS])
6438
+ and read_json(os.path.join(GOOSE_PLUGIN, "plugin.json"), {}).get("name") == "tezgah"),
6439
+ # a plugin named in `disabledPlugins` is skipped at discovery (plugins guide)
6440
+ ("tezgah plugin not in disabledPlugins (settings.json)",
6441
+ not (isinstance(disabled, list) and "tezgah" in disabled)),
6442
+ ("skills linked", skills_linked(os.path.join(GOOSE_PLUGIN, "skills"))),
6443
+ (".goosehints carries the contract (matches a fresh render)", rules_current("goose")),
6444
+ ("goose CLI on PATH", bool(tp.which_user("goose"))),
6445
+ ]
6446
+
6447
+
6448
+ def host_checks_amp():
6449
+ try:
6450
+ with open(AMP_PLUGIN, encoding="utf-8") as fh:
6451
+ plugin = fh.read()
6452
+ except OSError:
6453
+ plugin = ""
6454
+ # the file install and uninstall resolve, read with comments tolerated
6455
+ spath, settings = amp_settings(None)
6456
+ settings, name = settings or {}, os.path.basename(spath)
6457
+ return [
6458
+ # byte for byte, as omp's bridge row: a hand edit or an older tree's
6459
+ # render is not the wiring this tree arms
6460
+ ("plugin tezgah.ts current (matches a fresh render): %s" % AMP_PLUGIN,
6461
+ bool(plugin) and plugin == ta.amp_plugin(HERE)),
6462
+ ("skills linked", skills_linked(os.path.join(AMP, "skills"))),
6463
+ ("AGENTS.md carries the contract (matches a fresh render)", rules_current("amp")),
6464
+ ("%s off in %s" % (AMP_ATTRIBUTION[0], name),
6465
+ settings.get(AMP_ATTRIBUTION[0]) is False),
6466
+ ("%s off in %s" % (AMP_ATTRIBUTION[1], name),
6467
+ settings.get(AMP_ATTRIBUTION[1]) is False),
6468
+ ("amp CLI on PATH", bool(tp.which_user("amp"))),
6469
+ ]
6470
+
6471
+
4853
6472
  def host_checks_dsh():
4854
6473
  try:
4855
6474
  with open(DSH_PATCH, encoding="utf-8") as fh:
@@ -4968,7 +6587,11 @@ def host_checks_omp():
4968
6587
 
4969
6588
  HOST_CHECKS = {"claude": host_checks_claude, "codex": host_checks_codex,
4970
6589
  "opencode": host_checks_opencode, "cursor": host_checks_cursor,
4971
- "dsh": host_checks_dsh, "omp": host_checks_omp}
6590
+ "dsh": host_checks_dsh, "omp": host_checks_omp, "qwen": host_checks_qwen,
6591
+ "droid": host_checks_droid, "commandcode": host_checks_commandcode,
6592
+ "copilot": host_checks_copilot, "gemini": host_checks_gemini,
6593
+ "antigravity": host_checks_antigravity, "goose": host_checks_goose,
6594
+ "amp": host_checks_amp}
4972
6595
 
4973
6596
 
4974
6597
  def versions_agree():
@@ -5682,6 +7305,9 @@ def upgrade(version, dry_run):
5682
7305
  return 127
5683
7306
  import tezgah_update
5684
7307
  old = tezgah_update.installed_entries(hosts)
7308
+ # flushed first: through a pipe the plan sat in the buffer and printed after
7309
+ # the children's own output (B-099)
7310
+ sys.stdout.flush()
5685
7311
  proc = subprocess.run(cmd)
5686
7312
  if proc.returncode != 0:
5687
7313
  print("%s %s exited %d; tezgah was not re-armed"
@@ -5705,6 +7331,7 @@ def upgrade(version, dry_run):
5705
7331
  print("%s fetched, not re-armed: %s. Re-arm later with `%s`"
5706
7332
  % (NO, tezgah_update.NOT_REARMED["prefix"], " ".join(argv)))
5707
7333
  return 1
7334
+ sys.stdout.flush()
5708
7335
  return subprocess.run(argv).returncode
5709
7336
 
5710
7337
 
@@ -5774,10 +7401,27 @@ def install_all(hosts, new_roots, deps_off, dry_run, redetect):
5774
7401
  return 1 if missed or REFUSED else 0
5775
7402
 
5776
7403
 
7404
+ def python_floor(version):
7405
+ """The refusal for an interpreter below the Python 3.10 floor the README
7406
+ states (OQ-16: CI tests nothing older), or None. `version` is a
7407
+ sys.version_info-shaped tuple, so the check runs without that interpreter."""
7408
+ if tuple(version[:2]) >= (3, 10):
7409
+ return None
7410
+ return ("tezgah needs Python 3.10 or newer; %s is %s"
7411
+ % (sys.executable, ".".join(str(p) for p in version[:3])))
7412
+
7413
+
5777
7414
  def main(argv):
7415
+ why = python_floor(sys.version_info)
7416
+ if why:
7417
+ sys.stderr.write("tezgah-setup: %s\n" % why)
7418
+ return 1
7419
+ # no prefix matching: `--upgrade V --vers` read `--vers` as `--version`,
7420
+ # printed it and moved nothing, past the refusal below (B-023)
5778
7421
  ap = argparse.ArgumentParser(description="arm tezgah in every installed host; "
5779
7422
  "`tezgah update [--dry-run]` moves this install "
5780
- "to the newest release and re-arms it")
7423
+ "to the newest release and re-arms it",
7424
+ allow_abbrev=False)
5781
7425
  ap.add_argument("--install", action="store_true")
5782
7426
  ap.add_argument("--wizard", action="store_true",
5783
7427
  help="ask the install questions (a bare run on a terminal does this)")
@@ -5910,6 +7554,8 @@ def main(argv):
5910
7554
  paths = refresh_contract()
5911
7555
  for path in paths:
5912
7556
  print("%s refreshed %s" % (OK, path))
7557
+ if REFUSED:
7558
+ return 1 # each refused file was named above; it is not current
5913
7559
  if not paths:
5914
7560
  print("contract is current")
5915
7561
  return 0
@@ -5954,8 +7600,8 @@ def main(argv):
5954
7600
  wizard_mode = (args.wizard or bare_tty) and not args.report
5955
7601
 
5956
7602
  if args.uninstall:
5957
- uninstall(hosts, dry_run=args.dry_run)
5958
- return 0
7603
+ # 1 while anything tezgah wrote survives: the verify pass counts it
7604
+ return uninstall(hosts, dry_run=args.dry_run)
5959
7605
 
5960
7606
  # A selection change is its own command: it persists the choice and brings
5961
7607
  # every host the install recorded along, so `--enable`/`--disable` reach the