@r1z4x/tezgah 1.13.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 (93) hide show
  1. package/CHANGELOG.md +175 -1
  2. package/MANIFEST +18 -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 +15 -8
  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 +157 -41
  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 +12 -6
  27. package/bin/tezgah-rollback +3 -0
  28. package/bin/tezgah-route +1 -1
  29. package/bin/tezgah-setup +596 -157
  30. package/bin/tezgah-status +11 -1
  31. package/bin/tezgah-task +3 -0
  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 +3 -3
  38. package/docs/contract.md +77 -31
  39. package/docs/environment.md +58 -24
  40. package/docs/evidence.md +49 -19
  41. package/docs/gate.md +71 -21
  42. package/docs/hosts.md +65 -52
  43. package/docs/layers.md +1 -1
  44. package/docs/operations.md +67 -24
  45. package/docs/research.md +1 -1
  46. package/docs/skills.md +31 -8
  47. package/docs/status-line.md +33 -23
  48. package/docs/testing.md +48 -17
  49. package/docs/vision.md +6 -2
  50. package/hooks/lang/en.json +5 -2
  51. package/hooks/lang/tr.json +3 -2
  52. package/hooks/projects-posttooluse.py +11 -3
  53. package/hooks/projects-stop.py +1 -1
  54. package/hooks/tezgah_context.py +40 -23
  55. package/hooks/tezgah_gate.py +404 -106
  56. package/hooks/tezgah_integrity.py +367 -97
  57. package/hooks/tezgah_lessons.py +5 -2
  58. package/hooks/tezgah_paths.py +17 -6
  59. package/hooks/tezgah_policy.py +13 -5
  60. package/hooks/tezgah_skill_pick.py +1 -1
  61. package/hooks/tezgah_snapshot.py +6 -3
  62. package/hooks/tezgah_stopspec.py +2 -1
  63. package/hosts/amp/hook.py +1 -1
  64. package/hosts/amp/tezgah-plugin.ts.in +13 -3
  65. package/hosts/antigravity/hook.py +1 -1
  66. package/hosts/codex/hook.py +1 -1
  67. package/hosts/commandcode/hook.py +1 -1
  68. package/hosts/copilot/hook.py +1 -1
  69. package/hosts/cursor/hook.py +2 -1
  70. package/hosts/droid/hook.py +1 -1
  71. package/hosts/gemini/hook.py +1 -1
  72. package/hosts/goose/hook.py +1 -1
  73. package/hosts/omp/hook.py +1 -1
  74. package/hosts/omp/tezgah-hook.ts.in +127 -35
  75. package/hosts/opencode/plugins/tezgah.js +209 -63
  76. package/hosts/qwen/hook.py +1 -1
  77. package/output-styles/tezgah.md +1 -1
  78. package/package.json +6 -3
  79. package/packaging/build.sh +4 -4
  80. package/packaging/install.sh +11 -0
  81. package/packaging/upgrade.sh +3 -0
  82. package/skills/ai-research/LICENSE +21 -0
  83. package/skills/design-contract/SKILL.md +27 -6
  84. package/skills/design-library/LICENSE +21 -0
  85. package/skills/plan-add/render_table.py +7 -0
  86. package/skills/pm-frameworks/LICENSE +21 -0
  87. package/skills/rl-env/LICENSE +201 -0
  88. package/skills/tezgah-contract/SKILL.md +2 -2
  89. package/third_party/open-code-review/LICENSE +201 -0
  90. package/third_party/open-code-review/README.md +15 -0
  91. package/third_party/open-code-review/prompts/main_task_system.md +26 -0
  92. package/third_party/open-code-review/prompts/review_filter_task_system.md +13 -0
  93. package/workflows/graph-map.js +14 -5
package/bin/tezgah-setup CHANGED
@@ -262,6 +262,11 @@ SKILL_ROUTER = os.path.join(tp.CONFIG_DIR, "opencode-skills.md")
262
262
  # Where `link()` records the entries it had to materialise instead of
263
263
  # symlinking; see materialised_paths() for why the fact has to be written down.
264
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"
265
270
  # Every kill switch the code reads (`off("...")` under hooks/ and bin/;
266
271
  # `bin/tezgah-docs --citations` checks the two agree). The canonical
267
272
  # copies die with ~/.config/tezgah; the pre-multi-host setup wrote them to
@@ -280,7 +285,8 @@ OFF_SWITCH_NAMES = ("adhd-off", "agents-off", "consult-off", "design-off", "docs
280
285
  # moved aside - the only copy of them on disk.
281
286
  GENERATED_STATE = frozenset((
282
287
  "bin", "config.json", "contract.sha256", "contract.sha256.lock",
283
- "contract.sha256.tmp", "install.log", "materialised",
288
+ "contract.sha256.tmp", "install-prior.json", "install-prior.json.tmp",
289
+ "install.log", "materialised",
284
290
  "opencode-contract.md", "opencode-skills.md", "opencode-skills.full.md",
285
291
  "ponytail.level", "state"))
286
292
  # The predecessor harness's marker in an AGENTS.md under a root: the block tells
@@ -428,7 +434,20 @@ OK, NO = " ok ", " MISS "
428
434
 
429
435
 
430
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)
431
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)
432
451
 
433
452
 
434
453
  def expand(p):
@@ -657,6 +676,132 @@ def record_materialised(path, add=True):
657
676
  pass
658
677
 
659
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
+
660
805
  def is_tezgah_copy(path):
661
806
  """True when `path` is a real entry this installer materialised where a
662
807
  symlink was refused. A same-named file tezgah never wrote is the user's."""
@@ -745,39 +890,92 @@ def read_json(path, default):
745
890
  return data
746
891
 
747
892
 
748
- # 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.
749
894
  REFUSED = []
750
895
 
751
896
 
752
- 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"):
753
915
  """A host config tezgah is about to rewrite: `empty` when the file is absent,
754
916
  its object when it parses as one, None otherwise - named, recorded in
755
917
  REFUSED, and never written, because a write would replace the user's file
756
- 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."""
757
921
  try:
758
922
  with open(path, encoding="utf-8") as fh:
759
- data = json.load(fh)
923
+ text = fh.read()
924
+ data = json.loads(text)
760
925
  except FileNotFoundError:
761
926
  return empty
762
927
  except (OSError, ValueError) as exc:
763
- 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)
764
933
  else:
765
934
  if isinstance(data, dict):
766
935
  return data
767
936
  why = "not a JSON object: the top level is %s" % type(data).__name__
768
- print("%s %s (%s; left untouched - fix it, then re-run --install)"
769
- % (NO, path, why))
770
- REFUSED.append(path)
937
+ refuse(path, why, rerun)
771
938
  return None
772
939
 
773
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
+
774
962
  def write_json(path, data):
775
- """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.
776
965
 
777
966
  An unchanged write is not free: it moves a host file's mtime and lays down a
778
967
  `.tezgah-bak`, so a command whose whole point is idempotence (a repeated
779
- --disable) would report a change it never made. Returns True when it wrote."""
780
- return write_text(path, 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 ""))
781
979
 
782
980
 
783
981
  def write_text(path, text):
@@ -926,6 +1124,7 @@ def set_managed_block(path, block, start="# tezgah:start", end="# tezgah:end"):
926
1124
  out.pop()
927
1125
  if not out:
928
1126
  out = ["# dsh home patch layer - tezgah-setup manages the marked block below"]
1127
+ note_created(path)
929
1128
  mkdirp(os.path.dirname(path))
930
1129
  text = "\n".join(out) + "\n" + start + " (managed by tezgah-setup; do not edit)\n" \
931
1130
  + block.strip() + "\n" + end + "\n"
@@ -1189,7 +1388,13 @@ def install_common(new_roots, hosts):
1189
1388
  # older one, so an upgrade changes no one's reply language
1190
1389
  cfg["language"] = tp.env_language() if first else "tr"
1191
1390
  if own:
1192
- 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))
1193
1398
  print("%s %s roots=%s" % (OK, tp.CONFIG, cfg["roots"]))
1194
1399
  for name, src in [
1195
1400
  ("consult", os.path.join(HERE, "bin", "consult")),
@@ -1202,6 +1407,9 @@ def install_common(new_roots, hosts):
1202
1407
  ("tezgah-rollback", os.path.join(HERE, "bin", "tezgah-rollback")),
1203
1408
  ("tezgah-agents", os.path.join(HERE, "bin", "tezgah-agents")),
1204
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")),
1205
1413
  ("tezgah-capture", os.path.join(HERE, "bin", "tezgah-capture")),
1206
1414
  ("tezgah-doctor", os.path.join(HERE, "bin", "tezgah-doctor")),
1207
1415
  ("tezgah-setup", os.path.join(HERE, "bin", "tezgah-setup")),
@@ -1314,7 +1522,7 @@ def install_claude():
1314
1522
  # tracked project file. Claude's hook drops the core when the block is there
1315
1523
  # (`TEZGAH_CORE_IN_FILE`, hooks/projects-auto-init.py), so a session pays for
1316
1524
  # the contract once.
1317
- print("%s %s (always-on contract)" % (OK, write_rules("claude")))
1525
+ write_rules("claude")
1318
1526
  write_user_agents("claude")
1319
1527
  register_claude_plugin()
1320
1528
 
@@ -1477,7 +1685,8 @@ def wire_claude_attribution():
1477
1685
 
1478
1686
  def wire_claude_statusline():
1479
1687
  """Point Claude's statusLine at tezgah's wrapper (it forwards to Orca, then
1480
- 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."""
1481
1690
  sp = os.path.join(CLAUDE, "settings.json")
1482
1691
  settings = read_json(sp, None)
1483
1692
  if settings is None:
@@ -1487,6 +1696,8 @@ def wire_claude_statusline():
1487
1696
  if (settings.get("statusLine") or {}).get("command") == cmd:
1488
1697
  print("%s statusLine -> tezgah" % OK)
1489
1698
  return
1699
+ if "statusline.py" not in json.dumps(settings.get("statusLine") or {}):
1700
+ remember_prior(sp, settings, "statusLine")
1490
1701
  settings["statusLine"] = {"type": "command", "command": cmd}
1491
1702
  write_json(sp, settings)
1492
1703
  print("%s statusLine -> tezgah (previous kept as %s.<timestamp>.tezgah-bak)" % (OK, sp))
@@ -1533,7 +1744,7 @@ def install_codex():
1533
1744
  # SessionStart hook's context is one message, where this file is the
1534
1745
  # instructions Codex keeps for the whole session. Global, like omp's
1535
1746
  # RULES.md, because a per-project file would be a tracked project file.
1536
- print("%s %s (always-on contract)" % (OK, write_rules("codex")))
1747
+ write_rules("codex")
1537
1748
  write_user_agents("codex")
1538
1749
 
1539
1750
 
@@ -1658,9 +1869,11 @@ def strip_toml_mcp(path, names):
1658
1869
  return False
1659
1870
  out, skip, changed = [], False, False
1660
1871
  for line in lines:
1661
- m = re.match(r"\[mcp_servers\.([^\]]+)\]", line.strip())
1662
- if m:
1663
- 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
1664
1877
  changed = changed or skip
1665
1878
  if not skip:
1666
1879
  out.append(line)
@@ -1719,8 +1932,12 @@ def write_artifact(path, text, write):
1719
1932
 
1720
1933
  The writers are idempotent (write_if_changed, set_managed_md): a re-render
1721
1934
  that produces the same bytes moves no mtime and lays no backup. Returns True
1722
- 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)
1723
1938
  changed = write(path, text)
1939
+ if len(REFUSED) > refused:
1940
+ return None
1724
1941
  if recorded_shas().get(path) != _sha(text):
1725
1942
  record_contract(path, text)
1726
1943
  return changed
@@ -2055,7 +2272,10 @@ def install_opencode():
2055
2272
  {"$schema": "https://opencode.ai/config.json"})
2056
2273
  if cfg is None:
2057
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
2058
2277
  instr = cfg.get("instructions") or []
2278
+ wired = any(isinstance(i, str) and "tezgah" in i for i in instr)
2059
2279
  for extra in (contract, SKILL_ROUTER):
2060
2280
  if extra not in instr:
2061
2281
  instr.append(extra)
@@ -2103,6 +2323,9 @@ def install_opencode():
2103
2323
  # "[Old tool result content cleared]" so a long session stops re-sending
2104
2324
  # every output on every step. setdefault keeps a user's explicit choice.
2105
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")
2106
2329
  cfg.setdefault("compaction", {}).setdefault("prune", True)
2107
2330
  wat = cfg.setdefault("watcher", {})
2108
2331
  ign = wat.setdefault("ignore", [])
@@ -2174,6 +2397,12 @@ def install_cursor():
2174
2397
  cc = os.path.join(CURSOR, "cli-config.json")
2175
2398
  cfg = read_config(cc, {})
2176
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")
2177
2406
  cfg.setdefault("version", 1)
2178
2407
  cfg["statusLine"] = {
2179
2408
  "type": "command",
@@ -2235,7 +2464,10 @@ def install_qwen():
2235
2464
  arr = hooks.setdefault(ev, [])
2236
2465
  arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
2237
2466
  arr.append(group)
2238
- # Qwen's command status line: the session JSON on stdin, ANSI kept
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")
2239
2471
  data.setdefault("ui", {})["statusLine"] = {
2240
2472
  "type": "command",
2241
2473
  "command": tp.hook_command(
@@ -2246,7 +2478,7 @@ def install_qwen():
2246
2478
  print("%s %s (hooks + ui.statusLine)" % (OK, path))
2247
2479
  for s in SKILLS:
2248
2480
  print(link(os.path.join(HERE, "skills", s), os.path.join(QWEN, "skills", s)))
2249
- print("%s %s (always-on contract)" % (OK, write_rules("qwen")))
2481
+ write_rules("qwen")
2250
2482
 
2251
2483
 
2252
2484
  def droid_hook_groups():
@@ -2275,7 +2507,7 @@ def read_jsonc(path):
2275
2507
  text = fh.read()
2276
2508
  except FileNotFoundError:
2277
2509
  return {}
2278
- except OSError:
2510
+ except (OSError, ValueError): # unreadable, or not UTF-8
2279
2511
  return None
2280
2512
  return jsonc_loads(text)
2281
2513
 
@@ -2387,7 +2619,7 @@ def install_droid():
2387
2619
  text = fh.read()
2388
2620
  except FileNotFoundError:
2389
2621
  text = "{}\n"
2390
- except OSError:
2622
+ except (OSError, ValueError): # unreadable, or not UTF-8
2391
2623
  text = None
2392
2624
  # the command's stdout is drawn above the input (settings reference)
2393
2625
  line = {"command": droid_statusline()}
@@ -2400,6 +2632,10 @@ def install_droid():
2400
2632
  elif isinstance(plain, dict):
2401
2633
  # plain JSON (an earlier install wrote it): set both keys and rewrite, as before
2402
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")
2403
2639
  if ours or "statusLine" not in plain:
2404
2640
  plain["statusLine"] = line
2405
2641
  plain["includeCoAuthoredByDroid"] = False
@@ -2407,7 +2643,12 @@ def install_droid():
2407
2643
  print("%s %s (statusLine%s, includeCoAuthoredByDroid false)" % (
2408
2644
  OK, spath, "" if plain["statusLine"] == line else " left as the user set it"))
2409
2645
  elif text is not None:
2410
- old = (jsonc_loads(text) or {}).get("statusLine")
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")
2411
2652
  stale = old != line and "tezgah-statusline" in json.dumps(old or "")
2412
2653
  if stale:
2413
2654
  dropped = jsonc_drop(text, "statusLine", old) # an earlier install's line
@@ -2429,7 +2670,7 @@ def install_droid():
2429
2670
  "`includeCoAuthoredByDroid` to false" % (NO, spath, droid_statusline()))
2430
2671
  for s in SKILLS:
2431
2672
  print(link(os.path.join(HERE, "skills", s), os.path.join(DROID, "skills", s)))
2432
- print("%s %s (always-on contract)" % (OK, write_rules("droid")))
2673
+ write_rules("droid")
2433
2674
 
2434
2675
 
2435
2676
  def commandcode_hook_groups():
@@ -2454,11 +2695,19 @@ def install_commandcode():
2454
2695
  path = os.path.join(COMMANDCODE, "settings.json")
2455
2696
  data = read_config(path, {})
2456
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") == ""
2457
2703
  hooks = data.setdefault("hooks", {})
2458
2704
  for ev, group in commandcode_hook_groups().items():
2459
2705
  arr = hooks.setdefault(ev, [])
2460
2706
  arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
2461
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")
2462
2711
  attr = data.setdefault("attribution", {})
2463
2712
  if isinstance(attr, dict) and "commit" not in attr:
2464
2713
  attr["commit"] = ""
@@ -2469,7 +2718,7 @@ def install_commandcode():
2469
2718
  "co-author trailer" % (NO, path))
2470
2719
  for s in SKILLS:
2471
2720
  print(link(os.path.join(HERE, "skills", s), os.path.join(COMMANDCODE, "skills", s)))
2472
- print("%s %s (always-on contract)" % (OK, write_rules("commandcode")))
2721
+ write_rules("commandcode")
2473
2722
 
2474
2723
 
2475
2724
  def copilot_hook_entries():
@@ -2505,9 +2754,16 @@ def install_copilot():
2505
2754
  copilot_hook_entries().items()}})
2506
2755
  print("%s %s (hooks)" % (OK, path))
2507
2756
  spath = os.path.join(COPILOT, "settings.json")
2508
- settings = read_json(spath, None) if os.path.exists(spath) else {}
2757
+ settings = read_config(spath, {}, comments=True)
2509
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")
2510
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")
2511
2767
  settings.setdefault("includeCoAuthoredBy", False)
2512
2768
  write_json(spath, settings)
2513
2769
  print("%s %s (statusLine + includeCoAuthoredBy)" % (OK, spath))
@@ -2515,12 +2771,11 @@ def install_copilot():
2515
2771
  print("%s %s keeps your includeCoAuthoredBy, so commits still carry a "
2516
2772
  "co-author trailer" % (NO, spath))
2517
2773
  else:
2518
- print("%s %s holds comments, which a rewrite would drop: set `statusLine` to "
2519
- "`%s` and `includeCoAuthoredBy` to false there" % (NO, spath,
2520
- copilot_statusline()))
2774
+ print("%s set `statusLine` to `%s` and `includeCoAuthoredBy` to false in %s "
2775
+ "by hand" % (NO, copilot_statusline(), spath))
2521
2776
  for s in SKILLS:
2522
2777
  print(link(os.path.join(HERE, "skills", s), os.path.join(COPILOT, "skills", s)))
2523
- print("%s %s (always-on contract)" % (OK, write_rules("copilot")))
2778
+ write_rules("copilot")
2524
2779
 
2525
2780
 
2526
2781
  def gemini_hook_groups():
@@ -2549,7 +2804,7 @@ def install_gemini():
2549
2804
  print("%s %s (extension hooks)" % (OK, path))
2550
2805
  for s in SKILLS:
2551
2806
  print(link(os.path.join(HERE, "skills", s), os.path.join(GEMINI_EXT, "skills", s)))
2552
- print("%s %s (always-on contract)" % (OK, write_rules("gemini")))
2807
+ write_rules("gemini")
2553
2808
 
2554
2809
 
2555
2810
  def antigravity_hook_groups():
@@ -2584,18 +2839,19 @@ def install_antigravity():
2584
2839
  write_json(AGY_HOOKS, data)
2585
2840
  print("%s %s (hooks; the Antigravity App and IDE load them too)" % (OK, AGY_HOOKS))
2586
2841
  spath = os.path.join(ANTIGRAVITY, "settings.json")
2587
- settings = read_json(spath, None) if os.path.exists(spath) else {}
2842
+ settings = read_config(spath, {}, comments=True)
2588
2843
  if isinstance(settings, dict):
2844
+ if "tezgah-statusline" not in json.dumps(settings.get("statusLine") or {}):
2845
+ remember_prior(spath, settings, "statusLine")
2589
2846
  settings["statusLine"] = {"type": "command", "command": antigravity_statusline()}
2590
2847
  write_json(spath, settings)
2591
2848
  print("%s %s (statusLine)" % (OK, spath))
2592
2849
  else:
2593
- print("%s %s is not plain JSON, which a rewrite would lose: set `statusLine` "
2594
- "to {\"type\": \"command\", \"command\": \"%s\"} there"
2595
- % (NO, spath, antigravity_statusline()))
2850
+ print("%s set `statusLine` to {\"type\": \"command\", \"command\": \"%s\"} in "
2851
+ "%s by hand" % (NO, antigravity_statusline(), spath))
2596
2852
  for s in SKILLS:
2597
2853
  print(link(os.path.join(HERE, "skills", s), os.path.join(ANTIGRAVITY, "skills", s)))
2598
- print("%s %s (always-on contract)" % (OK, write_rules("antigravity")))
2854
+ write_rules("antigravity")
2599
2855
 
2600
2856
 
2601
2857
  def goose_hook_groups():
@@ -2626,18 +2882,28 @@ def install_goose():
2626
2882
  print("%s %s (plugin hooks)" % (OK, path))
2627
2883
  for s in SKILLS:
2628
2884
  print(link(os.path.join(HERE, "skills", s), os.path.join(GOOSE_PLUGIN, "skills", s)))
2629
- print("%s %s (always-on contract)" % (OK, write_rules("goose")))
2885
+ write_rules("goose")
2630
2886
 
2631
2887
 
2632
- def amp_settings():
2633
- """(path, settings dict or None): Amp's user settings.json as tezgah may
2634
- rewrite it - absent reads as {}, and None stands for a file tezgah must not
2635
- touch: one that is not plain JSON, or a settings.jsonc beside it (which of
2636
- the two wins is undocumented)."""
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."""
2637
2895
  path = os.path.join(AMP, "settings.json")
2638
- if os.path.exists(os.path.join(AMP, "settings.jsonc")):
2639
- return path, None
2640
- return path, read_json(path, None) if os.path.exists(path) else {}
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)
2641
2907
 
2642
2908
 
2643
2909
  def install_amp():
@@ -2647,6 +2913,7 @@ def install_amp():
2647
2913
  block in `~/.config/amp/AGENTS.md`; and Amp's two commit trailers set off
2648
2914
  in settings.json unless the user set them."""
2649
2915
  print("\namp:")
2916
+ wired = amp_plugin_ours() # an earlier install's plugin, before this one writes
2650
2917
  plugin = ta.amp_plugin(HERE)
2651
2918
  if not plugin:
2652
2919
  print("%s %s: this tree has no hosts/amp/tezgah-plugin.ts.in to render"
@@ -2657,6 +2924,10 @@ def install_amp():
2657
2924
  spath, settings = amp_settings()
2658
2925
  if isinstance(settings, dict):
2659
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)
2660
2931
  settings.setdefault(key, False)
2661
2932
  write_json(spath, settings)
2662
2933
  print("%s %s (commit trailers off)" % (OK, spath))
@@ -2665,11 +2936,11 @@ def install_amp():
2665
2936
  print("%s %s keeps your %s, so the agent's commits still carry "
2666
2937
  "that trailer" % (NO, spath, key))
2667
2938
  else:
2668
- print("%s %s is not plain JSON (or settings.jsonc exists): set %s to false "
2669
- "there" % (NO, spath, " and ".join("`%s`" % k for k in AMP_ATTRIBUTION)))
2939
+ print("%s set %s to false in %s by hand"
2940
+ % (NO, " and ".join("`%s`" % k for k in AMP_ATTRIBUTION), spath))
2670
2941
  for s in SKILLS:
2671
2942
  print(link(os.path.join(HERE, "skills", s), os.path.join(AMP, "skills", s)))
2672
- print("%s %s (always-on contract)" % (OK, write_rules("amp")))
2943
+ write_rules("amp")
2673
2944
 
2674
2945
 
2675
2946
 
@@ -2916,12 +3187,17 @@ def write_if_changed(path, text):
2916
3187
  def set_managed_md(path, block, start="<!-- tezgah:start -->",
2917
3188
  end="<!-- tezgah:end -->"):
2918
3189
  """Replace the marked block in a markdown file, or append it, keeping any
2919
- 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."""
2920
3193
  try:
2921
3194
  with open(path, encoding="utf-8") as fh:
2922
3195
  lines = fh.read().splitlines()
2923
- except OSError:
3196
+ except FileNotFoundError:
2924
3197
  lines = []
3198
+ except (OSError, ValueError) as exc:
3199
+ refuse(path, read_failure(exc))
3200
+ return False
2925
3201
  out, skip = [], False
2926
3202
  for ln in lines:
2927
3203
  if ln.strip().startswith(start):
@@ -3018,7 +3294,10 @@ def strip_managed_md(path):
3018
3294
  try:
3019
3295
  with open(path, encoding="utf-8") as fh:
3020
3296
  text = fh.read()
3021
- except OSError:
3297
+ except FileNotFoundError:
3298
+ return
3299
+ except (OSError, ValueError) as exc:
3300
+ refuse(path, read_failure(exc), "--uninstall")
3022
3301
  return
3023
3302
  kept = re.sub(r"<!-- tezgah:start -->.*?<!-- tezgah:end -->\n?",
3024
3303
  "", text, flags=re.S).strip()
@@ -3092,10 +3371,11 @@ def rules_block(host):
3092
3371
 
3093
3372
 
3094
3373
  def write_rules(host):
3095
- """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`."""
3096
3376
  path = STATIC_RULES[host]()
3097
- write_artifact(path, rules_block(host), set_managed_md)
3098
- 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))
3099
3379
 
3100
3380
 
3101
3381
  def rules_current(host):
@@ -3107,7 +3387,7 @@ def rules_current(host):
3107
3387
  try:
3108
3388
  with open(STATIC_RULES[host](), encoding="utf-8") as fh:
3109
3389
  lines = fh.read().splitlines()
3110
- except OSError:
3390
+ except (OSError, ValueError): # unreadable, or not UTF-8: not current
3111
3391
  return False
3112
3392
  starts = [i for i, ln in enumerate(lines)
3113
3393
  if ln.strip().startswith("<!-- tezgah:start -->")]
@@ -3128,7 +3408,7 @@ def omp_bridge():
3128
3408
  def install_omp():
3129
3409
  print("\nomp:")
3130
3410
  mkdirp(OMP_AGENT)
3131
- print("%s %s (always-on contract)" % (OK, write_rules("omp")))
3411
+ write_rules("omp")
3132
3412
  for s in SKILLS:
3133
3413
  print(link(os.path.join(HERE, "skills", s),
3134
3414
  os.path.join(OMP_AGENT, "skills", s)))
@@ -3656,9 +3936,11 @@ def is_tezgah_link(path):
3656
3936
  wiring the user's."""
3657
3937
  if not os.path.islink(path):
3658
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
3659
3941
  target = os.path.realpath(path)
3660
3942
  return any(target == root or target.startswith(root + os.sep)
3661
- 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)))
3662
3944
 
3663
3945
 
3664
3946
  def is_tezgah_entry(path):
@@ -3698,6 +3980,9 @@ def unlink_dir(d):
3698
3980
  def drop_managed_hook_entries(path):
3699
3981
  """Remove only tezgah's own entries from a host hooks.json."""
3700
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
3701
3986
  if not data:
3702
3987
  print("%s no %s" % (OK, path))
3703
3988
  return
@@ -3724,9 +4009,12 @@ def remove_managed_block(path, start="# tezgah:start", end="# tezgah:end"):
3724
4009
  try:
3725
4010
  with open(path, encoding="utf-8") as fh:
3726
4011
  lines = fh.read().splitlines()
3727
- except OSError:
4012
+ except FileNotFoundError:
3728
4013
  print("%s no %s" % (OK, path))
3729
4014
  return
4015
+ except (OSError, ValueError) as exc:
4016
+ refuse(path, read_failure(exc), "--uninstall")
4017
+ return
3730
4018
  out, skip, changed = [], False, False
3731
4019
  for ln in lines:
3732
4020
  stripped = ln.strip()
@@ -3749,8 +4037,17 @@ def remove_managed_block(path, start="# tezgah:start", end="# tezgah:end"):
3749
4037
  return
3750
4038
  while out and not out[-1].strip():
3751
4039
  out.pop()
3752
- # A dsh patch layer must stay a top-level YAML array; a file left holding
3753
- # 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)
3754
4051
  if not any(ln.strip() and not ln.strip().startswith("#") for ln in out):
3755
4052
  out.append("[]")
3756
4053
  with open(path, "w", encoding="utf-8") as fh:
@@ -3759,8 +4056,6 @@ def remove_managed_block(path, start="# tezgah:start", end="# tezgah:end"):
3759
4056
 
3760
4057
 
3761
4058
  def uninstall_common():
3762
- print("\ncommon:")
3763
- unlink_dir(tp.BIN_DIR)
3764
4059
  removed = tezgah_agents.cleanup()
3765
4060
  if removed:
3766
4061
  print("%s removed %d generated repo agent file(s)" % (OK, removed))
@@ -3870,20 +4165,21 @@ def _drop_state_entry(path):
3870
4165
 
3871
4166
 
3872
4167
  def remove_generated_state(full):
3873
- """Take tezgah's state back: the cache dirs always, the config dir's
3874
- 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.
3875
4170
 
3876
4171
  `full` means the run covers every host config.json records - the armed set.
3877
- A partial uninstall keeps the config: the hosts still armed read their roots
3878
- 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
3879
4175
  wiring this run did not touch. `adopted/` and any entry that is not one of
3880
4176
  tezgah's generated names survive in both cases."""
4177
+ if not full:
4178
+ return
3881
4179
  for d in (tp.CACHE, tp.fallback_cache()):
3882
4180
  if os.path.isdir(d):
3883
4181
  shutil.rmtree(d, ignore_errors=True)
3884
4182
  print("%s removed %s" % (OK, d))
3885
- if not full:
3886
- return
3887
4183
  try:
3888
4184
  names = sorted(os.listdir(tp.CONFIG_DIR))
3889
4185
  except OSError as exc:
@@ -3969,14 +4265,22 @@ def remove_install_tree():
3969
4265
  print("%s kept %s (holds %s)" % (NO, INSTALL_PREFIX, ", ".join(sorted(rest))))
3970
4266
 
3971
4267
 
3972
- def _text_clean(path, needle="tezgah"):
3973
- """(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."""
3974
4277
  try:
3975
4278
  with open(path, encoding="utf-8", errors="replace") as fh:
3976
4279
  body = fh.read()
3977
4280
  except OSError:
3978
4281
  return True, ""
3979
- 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
3980
4284
 
3981
4285
 
3982
4286
  def verify_uninstall(hosts, full):
@@ -3996,7 +4300,21 @@ def verify_uninstall(hosts, full):
3996
4300
  else:
3997
4301
  print("%s %s" % (OK, label))
3998
4302
 
3999
- 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):
4000
4318
  left = []
4001
4319
  for p in paths:
4002
4320
  ok, leftover = _text_clean(p, needle)
@@ -4008,13 +4326,17 @@ def verify_uninstall(hosts, full):
4008
4326
  else:
4009
4327
  print("%s %s" % (OK, label))
4010
4328
 
4011
- # 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
4012
4331
  farm = [os.path.join(tp.BIN_DIR, n)
4013
4332
  for n in (os.listdir(tp.BIN_DIR) if os.path.isdir(tp.BIN_DIR) else [])]
4014
- claim("config/bin farm holds no tezgah entry",
4015
- [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)])
4016
4338
  claim("materialised ledger gone", [MATERIALISED] if full else [])
4017
- claim("state caches gone", [tp.CACHE, tp.fallback_cache()])
4339
+ claim("state caches gone", [tp.CACHE, tp.fallback_cache()] if full else [])
4018
4340
  if full:
4019
4341
  try:
4020
4342
  has_tree = os.path.isdir(INSTALL_PREFIX) and any(
@@ -4038,11 +4360,11 @@ def verify_uninstall(hosts, full):
4038
4360
  for n in user_agent_files("claude")])
4039
4361
  if "codex" in hosts:
4040
4362
  clean("codex hooks.json unwired", [os.path.join(CODEX, "hooks.json")])
4041
- claim("codex skills unlinked",
4042
- [os.path.join(CODEX, "skills", s) for s in SKILLS
4043
- if os.path.lexists(os.path.join(CODEX, "skills", s))])
4044
- clean("codex config.toml unwired", [os.path.join(CODEX, "config.toml")])
4045
- 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 [])
4046
4368
  # the user's own AGENTS.md may name tezgah; only the block is ours
4047
4369
  clean("codex global AGENTS.md block gone",
4048
4370
  [os.path.join(CODEX, n) for n in CODEX_RULES],
@@ -4056,20 +4378,24 @@ def verify_uninstall(hosts, full):
4056
4378
  + [os.path.join(OPENCODE, "tui-plugins", n)
4057
4379
  for n in ("tezgah-tui.tsx", "tezgah-cli")])
4058
4380
  clean("opencode opencode.json unwired",
4059
- [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)
4060
4383
  clean("opencode cli.json unwired", [os.path.join(OPENCODE, "cli.json")],
4061
4384
  needle=OPENCODE_CLI_PLUGIN)
4062
4385
  claim("opencode generated routers gone", [SKILL_ROUTER, SKILL_ROUTER_FULL])
4063
4386
  if "cursor" in hosts:
4064
4387
  clean("cursor hooks.json unwired", [os.path.join(CURSOR, "hooks.json")])
4065
- 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)
4066
4390
  clean("cursor cli-config.json unwired",
4067
4391
  [os.path.join(CURSOR, "cli-config.json")])
4068
4392
  if "dsh" in hosts:
4069
- 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")
4070
4395
  claim("dsh launcher gone", [dsh_launcher()[1]])
4071
4396
  if "omp" in hosts:
4072
- 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")
4073
4399
  claim("omp hook script gone",
4074
4400
  [os.path.join(OMP_AGENT, "hooks", "pre", "tezgah-hook.ts")])
4075
4401
  agents = os.path.join(OMP_AGENT, "agents")
@@ -4077,34 +4403,27 @@ def verify_uninstall(hosts, full):
4077
4403
  [os.path.join(agents, n) for n in
4078
4404
  (os.listdir(agents) if os.path.isdir(agents) else [])
4079
4405
  if n[:-len(".md")] in tezgah_agents.OWNED_NAMES and n.endswith(".md")])
4080
- clean("omp mcp.json unwired", [os.path.join(OMP_AGENT, "mcp.json")])
4081
- claim("omp skills unlinked",
4082
- [os.path.join(OMP_AGENT, "skills", s) for s in SKILLS
4083
- 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))
4084
4409
  if "qwen" in hosts:
4085
4410
  clean("qwen settings.json unwired", [os.path.join(QWEN, "settings.json")])
4086
4411
  clean("qwen global QWEN.md block gone", [os.path.join(QWEN, "QWEN.md")],
4087
4412
  needle="<!-- tezgah:start")
4088
- claim("qwen skills unlinked",
4089
- [os.path.join(QWEN, "skills", s) for s in SKILLS
4090
- if os.path.lexists(os.path.join(QWEN, "skills", s))])
4413
+ claim("qwen skills unlinked", skills(QWEN))
4091
4414
  if "droid" in hosts:
4092
4415
  clean("droid hooks.json unwired", [os.path.join(DROID, "hooks.json")])
4093
4416
  clean("droid settings.json statusLine gone", [os.path.join(DROID, "settings.json")],
4094
4417
  needle="tezgah-statusline")
4095
4418
  clean("droid global AGENTS.md block gone", [os.path.join(DROID, "AGENTS.md")],
4096
4419
  needle="<!-- tezgah:start")
4097
- claim("droid skills unlinked",
4098
- [os.path.join(DROID, "skills", s) for s in SKILLS
4099
- if os.path.lexists(os.path.join(DROID, "skills", s))])
4420
+ claim("droid skills unlinked", skills(DROID))
4100
4421
  if "commandcode" in hosts:
4101
4422
  clean("commandcode settings.json unwired",
4102
4423
  [os.path.join(COMMANDCODE, "settings.json")])
4103
4424
  clean("commandcode global AGENTS.md block gone",
4104
4425
  [os.path.join(COMMANDCODE, "AGENTS.md")], needle="<!-- tezgah:start")
4105
- claim("commandcode skills unlinked",
4106
- [os.path.join(COMMANDCODE, "skills", s) for s in SKILLS
4107
- if os.path.lexists(os.path.join(COMMANDCODE, "skills", s))])
4426
+ claim("commandcode skills unlinked", skills(COMMANDCODE))
4108
4427
  if "copilot" in hosts:
4109
4428
  claim("copilot hooks/tezgah.json gone",
4110
4429
  [os.path.join(COPILOT, "hooks", "tezgah.json")])
@@ -4112,9 +4431,7 @@ def verify_uninstall(hosts, full):
4112
4431
  [os.path.join(COPILOT, "settings.json")], needle="tezgah-statusline")
4113
4432
  clean("copilot copilot-instructions.md block gone",
4114
4433
  [os.path.join(COPILOT, "copilot-instructions.md")], needle="<!-- tezgah:start")
4115
- claim("copilot skills unlinked",
4116
- [os.path.join(COPILOT, "skills", s) for s in SKILLS
4117
- if os.path.lexists(os.path.join(COPILOT, "skills", s))])
4434
+ claim("copilot skills unlinked", skills(COPILOT))
4118
4435
  if "gemini" in hosts:
4119
4436
  claim("gemini extension dir gone", [GEMINI_EXT])
4120
4437
  if not gemini_md_kept("gemini"):
@@ -4125,9 +4442,7 @@ def verify_uninstall(hosts, full):
4125
4442
  needle="tezgah-antigravity-hook")
4126
4443
  clean("antigravity settings.json statusLine gone",
4127
4444
  [os.path.join(ANTIGRAVITY, "settings.json")], needle="tezgah-statusline")
4128
- claim("antigravity skills unlinked",
4129
- [os.path.join(ANTIGRAVITY, "skills", s) for s in SKILLS
4130
- if os.path.lexists(os.path.join(ANTIGRAVITY, "skills", s))])
4445
+ claim("antigravity skills unlinked", skills(ANTIGRAVITY))
4131
4446
  if not gemini_md_kept("antigravity"):
4132
4447
  clean("antigravity GEMINI.md block gone", [STATIC_RULES["antigravity"]()],
4133
4448
  needle="<!-- tezgah:start")
@@ -4137,14 +4452,12 @@ def verify_uninstall(hosts, full):
4137
4452
  needle="<!-- tezgah:start")
4138
4453
  if "amp" in hosts:
4139
4454
  claim("amp plugin gone", [AMP_PLUGIN] if amp_plugin_ours() else [])
4140
- claim("amp skills unlinked",
4141
- [os.path.join(AMP, "skills", s) for s in SKILLS
4142
- if os.path.lexists(os.path.join(AMP, "skills", s))])
4455
+ claim("amp skills unlinked", skills(AMP))
4143
4456
  clean("amp AGENTS.md block gone", [STATIC_RULES["amp"]()],
4144
4457
  needle="<!-- tezgah:start")
4145
4458
  if "claude" in hosts:
4146
4459
  clean("claude settings.json unwired",
4147
- [os.path.join(CLAUDE, "settings.json")])
4460
+ [os.path.join(CLAUDE, "settings.json")], needle=farm_mark + ('"tezgah@',))
4148
4461
  claim("legacy kill switches gone",
4149
4462
  [os.path.join(HOME, ".claude", n) for n in OFF_SWITCH_NAMES
4150
4463
  if os.path.exists(os.path.join(HOME, ".claude", n))])
@@ -4158,16 +4471,16 @@ def verify_uninstall(hosts, full):
4158
4471
 
4159
4472
 
4160
4473
  def unwire_claude_settings():
4161
- """Remove tezgah's statusLine, attribution and enabledPlugins keys, leaving
4162
- 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."""
4163
4477
  sp = os.path.join(CLAUDE, "settings.json")
4164
4478
  s = read_json(sp, None)
4165
- if not s:
4479
+ if not s or not isinstance(s, dict):
4166
4480
  return
4167
4481
  changed = False
4168
4482
  if "statusline.py" in json.dumps(s.get("statusLine") or {}):
4169
- s.pop("statusLine", None)
4170
- changed = True
4483
+ changed = restore_prior(sp, s, "statusLine")
4171
4484
  if s.get("attribution") == {"commit": "", "pr": "", "sessionUrl": False}:
4172
4485
  s.pop("attribution", None)
4173
4486
  changed = True
@@ -4176,9 +4489,12 @@ def unwire_claude_settings():
4176
4489
  if isinstance(enabled, dict) else []:
4177
4490
  del enabled[key]
4178
4491
  changed = True
4492
+ if not enabled:
4493
+ s.pop("enabledPlugins")
4179
4494
  if changed:
4180
4495
  write_json(sp, s)
4181
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)
4182
4498
 
4183
4499
 
4184
4500
  def unwire_opencode_config():
@@ -4217,8 +4533,9 @@ def unwire_opencode_config():
4217
4533
  changed = True
4218
4534
  comp = c.get("compaction")
4219
4535
  if isinstance(comp, dict) and comp.get("prune") is True:
4220
- c.pop("compaction", None)
4221
- 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
4222
4539
  wat = c.get("watcher")
4223
4540
  if isinstance(wat, dict) and isinstance(wat.get("ignore"), list):
4224
4541
  kept = [p for p in wat["ignore"] if p not in OPENCODE_IGNORE]
@@ -4241,6 +4558,7 @@ def unwire_opencode_config():
4241
4558
  if changed:
4242
4559
  write_json(p, c)
4243
4560
  print("%s removed tezgah instructions/skill-deny from %s" % (OK, p))
4561
+ forget_prior(p)
4244
4562
  t = os.path.join(OPENCODE, "tui.json")
4245
4563
  tj = read_json(t, None)
4246
4564
  if tj and tj.get("plugin"):
@@ -4253,6 +4571,7 @@ def unwire_opencode_config():
4253
4571
  tj.pop("plugin", None)
4254
4572
  write_json(t, tj)
4255
4573
  print("%s removed tezgah TUI plugin from %s" % (OK, t))
4574
+ drop_if_empty(t, {"$schema": "https://opencode.ai/tui.json"})
4256
4575
  cp = os.path.join(OPENCODE, "cli.json")
4257
4576
  cj = read_json(cp, None)
4258
4577
  if cj and isinstance(cj.get("plugins"), list):
@@ -4261,6 +4580,7 @@ def unwire_opencode_config():
4261
4580
  cj["plugins"] = kept
4262
4581
  write_json(cp, cj)
4263
4582
  print("%s removed tezgah CLI plugin from %s" % (OK, cp))
4583
+ forget_prior(cp)
4264
4584
 
4265
4585
 
4266
4586
  def uninstall_claude():
@@ -4307,6 +4627,7 @@ def uninstall_codex():
4307
4627
  for s in SKILLS:
4308
4628
  unlink_tezgah(os.path.join(CODEX, "skills", s))
4309
4629
  drop_managed_hook_entries(os.path.join(CODEX, "hooks.json"))
4630
+ drop_if_empty(os.path.join(CODEX, "hooks.json"))
4310
4631
  strip_toml_mcp(os.path.join(CODEX, "config.toml"),
4311
4632
  app_mcp_names() + [TEZGAH_MCP_NAME])
4312
4633
  unlink_tezgah(os.path.join(CODEX, "bin", "consult"))
@@ -4338,6 +4659,7 @@ def uninstall_cursor():
4338
4659
  for s in SKILLS:
4339
4660
  unlink_tezgah(os.path.join(CURSOR, "skills", s))
4340
4661
  drop_managed_hook_entries(os.path.join(CURSOR, "hooks.json"))
4662
+ drop_if_empty(os.path.join(CURSOR, "hooks.json"), {"version": 1})
4341
4663
  mpath = os.path.join(CURSOR, "mcp.json")
4342
4664
  mcfg = read_json(mpath, None)
4343
4665
  if mcfg and isinstance(mcfg.get("mcpServers"), dict):
@@ -4352,9 +4674,13 @@ def uninstall_cursor():
4352
4674
  cfg = read_json(cc, None)
4353
4675
  if isinstance(cfg, dict) and "tezgah-statusline" in json.dumps(
4354
4676
  cfg.get("statusLine") or {}):
4355
- 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)
4356
4681
  write_json(cc, cfg)
4357
4682
  print("%s removed tezgah statusLine from %s" % (OK, cc))
4683
+ drop_if_empty(cc, {"version": 1})
4358
4684
 
4359
4685
 
4360
4686
  def uninstall_qwen():
@@ -4366,22 +4692,35 @@ def uninstall_qwen():
4366
4692
  cfg = read_json(path, None)
4367
4693
  ui = cfg.get("ui") if isinstance(cfg, dict) else None
4368
4694
  if isinstance(ui, dict) and "tezgah-statusline" in json.dumps(ui.get("statusLine") or {}):
4369
- ui.pop("statusLine")
4370
- if not ui:
4371
- cfg.pop("ui")
4695
+ restore_prior(path, cfg, "ui/statusLine")
4372
4696
  write_json(path, cfg)
4373
4697
  print("%s removed tezgah statusLine from %s" % (OK, path))
4374
4698
  drop_if_empty(path)
4375
4699
  strip_managed_md(os.path.join(QWEN, "QWEN.md"))
4376
4700
 
4377
4701
 
4378
- def drop_if_empty(path):
4379
- """Remove a host settings file uninstall emptied: tezgah created it to hold
4380
- its hooks or status line, and a `{}` the user never had is left over."""
4381
- if read_json(path, None) == {}:
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:
4382
4720
  backup(path)
4383
4721
  os.remove(path)
4384
4722
  print("%s removed %s (empty once tezgah's keys left)" % (OK, path))
4723
+ forget_prior(path)
4385
4724
 
4386
4725
 
4387
4726
  def uninstall_droid():
@@ -4410,18 +4749,38 @@ def uninstall_droid():
4410
4749
  print("%s removed %s (it held only tezgah's hooks)" % (OK, path))
4411
4750
  spath = os.path.join(DROID, "settings.json")
4412
4751
  cfg = read_json(spath, None)
4413
- if isinstance(cfg, dict) and "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
4414
- cfg.pop("statusLine")
4415
- write_json(spath, cfg)
4416
- print("%s removed tezgah statusLine from %s" % (OK, spath))
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))
4417
4765
  elif cfg is None and os.path.exists(spath):
4418
- # JSON with comments: take out the one line install_droid put in, keep
4419
- # the comments; `includeCoAuthoredByDroid` stays false (the
4420
- # no-attribution rule is the user's, not tezgah's to undo)
4421
- with open(spath, encoding="utf-8") as fh:
4422
- text = fh.read()
4423
- old = (jsonc_loads(text) or {}).get("statusLine")
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")
4424
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
4425
4784
  if new is not None:
4426
4785
  write_text(spath, new)
4427
4786
  print("%s removed tezgah statusLine from %s" % (OK, spath))
@@ -4437,13 +4796,10 @@ def uninstall_commandcode():
4437
4796
  drop_managed_hook_entries(path)
4438
4797
  cfg = read_json(path, None)
4439
4798
  attr = cfg.get("attribution") if isinstance(cfg, dict) else None
4440
- # the value install wrote, beside the user's other attribution keys or
4441
- # alone; as with Claude's, a user's own identical value cannot be told
4442
- # apart from it, and dropping it only restores Command Code's default
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)
4443
4801
  if isinstance(attr, dict) and attr.get("commit") == "":
4444
- attr.pop("commit")
4445
- if not attr:
4446
- cfg.pop("attribution")
4802
+ restore_prior(path, cfg, "attribution/commit")
4447
4803
  write_json(path, cfg)
4448
4804
  print("%s removed tezgah's attribution.commit from %s" % (OK, path))
4449
4805
  drop_if_empty(path)
@@ -4463,10 +4819,11 @@ def uninstall_copilot():
4463
4819
  cfg = read_json(spath, None)
4464
4820
  if isinstance(cfg, dict):
4465
4821
  if "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
4466
- cfg.pop("statusLine")
4467
- # the value install wrote; a user's own false cannot be told apart from it
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)
4468
4825
  if cfg.get("includeCoAuthoredBy") is False:
4469
- cfg.pop("includeCoAuthoredBy")
4826
+ restore_prior(spath, cfg, "includeCoAuthoredBy")
4470
4827
  if write_json(spath, cfg):
4471
4828
  print("%s removed tezgah's statusLine and includeCoAuthoredBy from %s"
4472
4829
  % (OK, spath))
@@ -4506,7 +4863,7 @@ def uninstall_antigravity():
4506
4863
  spath = os.path.join(ANTIGRAVITY, "settings.json")
4507
4864
  cfg = read_json(spath, None)
4508
4865
  if isinstance(cfg, dict) and "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
4509
- cfg.pop("statusLine")
4866
+ restore_prior(spath, cfg, "statusLine")
4510
4867
  write_json(spath, cfg)
4511
4868
  print("%s removed tezgah's statusLine from %s" % (OK, spath))
4512
4869
  drop_if_empty(spath)
@@ -4553,13 +4910,17 @@ def uninstall_amp():
4553
4910
  print("%s %s is not tezgah's render: left in place" % (NO, AMP_PLUGIN))
4554
4911
  for s in SKILLS:
4555
4912
  unlink_tezgah(os.path.join(AMP, "skills", s))
4556
- spath, settings = amp_settings()
4913
+ spath, settings = amp_settings("--uninstall")
4557
4914
  if isinstance(settings, dict) and any(settings.get(k) is False for k in AMP_ATTRIBUTION):
4558
4915
  for key in AMP_ATTRIBUTION:
4559
4916
  if settings.get(key) is False:
4560
- settings.pop(key)
4917
+ # a value the user had before the install comes back (OQ-15)
4918
+ restore_prior(spath, settings, key)
4561
4919
  write_json(spath, settings)
4562
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:
4563
4924
  drop_if_empty(spath)
4564
4925
  strip_managed_md(STATIC_RULES["amp"]())
4565
4926
 
@@ -4597,8 +4958,15 @@ def uninstall_omp():
4597
4958
  if isinstance(cfg, dict):
4598
4959
  servers = cfg.get("mcpServers")
4599
4960
  if isinstance(servers, dict):
4600
- for name in ["codegraph", TEZGAH_MCP_NAME] + app_mcp_names():
4961
+ for name in [TEZGAH_MCP_NAME] + app_mcp_names():
4601
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")
4602
4970
  if not servers:
4603
4971
  cfg.pop("mcpServers", None)
4604
4972
  # $schema was written by install_omp and nothing else: a file left
@@ -4614,6 +4982,34 @@ def uninstall_omp():
4614
4982
  strip_managed_md(os.path.join(OMP_AGENT, "RULES.md"))
4615
4983
 
4616
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
+
4617
5013
  UNINSTALLERS = {"claude": uninstall_claude, "codex": uninstall_codex,
4618
5014
  "opencode": uninstall_opencode, "cursor": uninstall_cursor,
4619
5015
  "dsh": uninstall_dsh, "omp": uninstall_omp, "qwen": uninstall_qwen,
@@ -4675,12 +5071,32 @@ def uninstall(hosts, dry_run=False):
4675
5071
  print("uninstalling for: %s (%s run)\n"
4676
5072
  % (", ".join(hosts) or "none detected", "full" if full else "partial"))
4677
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))
4678
5083
  uninstall_common()
4679
5084
  for h in hosts:
4680
5085
  UNINSTALLERS[h]()
4681
5086
  if "codex" in hosts and "cursor" in (armed or []) and "cursor" not in hosts:
4682
5087
  print("\ncursor (its skills came through ~/.codex/skills):")
4683
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)]
4684
5100
  if "claude" in hosts:
4685
5101
  print("\nclaude plugin copy:")
4686
5102
  remove_plugin_copies()
@@ -4699,6 +5115,7 @@ def uninstall(hosts, dry_run=False):
4699
5115
  write_json(tp.CONFIG, cfg)
4700
5116
  print("\n%s still armed: %s keeps its config and the install tree"
4701
5117
  % (NO, ", ".join(rest)))
5118
+ remove_created_dirs(created)
4702
5119
  remaining = verify_uninstall(hosts, full)
4703
5120
  print("\nrestart each host so in-memory wiring unloads.")
4704
5121
  pred = predecessors()
@@ -6034,10 +6451,9 @@ def host_checks_amp():
6034
6451
  plugin = fh.read()
6035
6452
  except OSError:
6036
6453
  plugin = ""
6037
- # either user settings file; the comment-tolerant reader for both
6038
- settings = (read_jsonc(os.path.join(AMP, "settings.json")) or {}
6039
- if not os.path.exists(os.path.join(AMP, "settings.jsonc"))
6040
- else read_jsonc(os.path.join(AMP, "settings.jsonc")) or {})
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)
6041
6457
  return [
6042
6458
  # byte for byte, as omp's bridge row: a hand edit or an older tree's
6043
6459
  # render is not the wiring this tree arms
@@ -6045,9 +6461,9 @@ def host_checks_amp():
6045
6461
  bool(plugin) and plugin == ta.amp_plugin(HERE)),
6046
6462
  ("skills linked", skills_linked(os.path.join(AMP, "skills"))),
6047
6463
  ("AGENTS.md carries the contract (matches a fresh render)", rules_current("amp")),
6048
- ("%s off in settings.json" % AMP_ATTRIBUTION[0],
6464
+ ("%s off in %s" % (AMP_ATTRIBUTION[0], name),
6049
6465
  settings.get(AMP_ATTRIBUTION[0]) is False),
6050
- ("%s off in settings.json" % AMP_ATTRIBUTION[1],
6466
+ ("%s off in %s" % (AMP_ATTRIBUTION[1], name),
6051
6467
  settings.get(AMP_ATTRIBUTION[1]) is False),
6052
6468
  ("amp CLI on PATH", bool(tp.which_user("amp"))),
6053
6469
  ]
@@ -6889,6 +7305,9 @@ def upgrade(version, dry_run):
6889
7305
  return 127
6890
7306
  import tezgah_update
6891
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()
6892
7311
  proc = subprocess.run(cmd)
6893
7312
  if proc.returncode != 0:
6894
7313
  print("%s %s exited %d; tezgah was not re-armed"
@@ -6912,6 +7331,7 @@ def upgrade(version, dry_run):
6912
7331
  print("%s fetched, not re-armed: %s. Re-arm later with `%s`"
6913
7332
  % (NO, tezgah_update.NOT_REARMED["prefix"], " ".join(argv)))
6914
7333
  return 1
7334
+ sys.stdout.flush()
6915
7335
  return subprocess.run(argv).returncode
6916
7336
 
6917
7337
 
@@ -6981,10 +7401,27 @@ def install_all(hosts, new_roots, deps_off, dry_run, redetect):
6981
7401
  return 1 if missed or REFUSED else 0
6982
7402
 
6983
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
+
6984
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)
6985
7421
  ap = argparse.ArgumentParser(description="arm tezgah in every installed host; "
6986
7422
  "`tezgah update [--dry-run]` moves this install "
6987
- "to the newest release and re-arms it")
7423
+ "to the newest release and re-arms it",
7424
+ allow_abbrev=False)
6988
7425
  ap.add_argument("--install", action="store_true")
6989
7426
  ap.add_argument("--wizard", action="store_true",
6990
7427
  help="ask the install questions (a bare run on a terminal does this)")
@@ -7117,6 +7554,8 @@ def main(argv):
7117
7554
  paths = refresh_contract()
7118
7555
  for path in paths:
7119
7556
  print("%s refreshed %s" % (OK, path))
7557
+ if REFUSED:
7558
+ return 1 # each refused file was named above; it is not current
7120
7559
  if not paths:
7121
7560
  print("contract is current")
7122
7561
  return 0
@@ -7161,8 +7600,8 @@ def main(argv):
7161
7600
  wizard_mode = (args.wizard or bare_tty) and not args.report
7162
7601
 
7163
7602
  if args.uninstall:
7164
- uninstall(hosts, dry_run=args.dry_run)
7165
- return 0
7603
+ # 1 while anything tezgah wrote survives: the verify pass counts it
7604
+ return uninstall(hosts, dry_run=args.dry_run)
7166
7605
 
7167
7606
  # A selection change is its own command: it persists the choice and brings
7168
7607
  # every host the install recorded along, so `--enable`/`--disable` reach the