@r1z4x/tezgah 1.9.2 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (445) hide show
  1. package/CHANGELOG.md +415 -1
  2. package/MANIFEST +404 -144
  3. package/README.de.md +1 -1
  4. package/README.es.md +1 -1
  5. package/README.fr.md +1 -1
  6. package/README.md +5 -4
  7. package/README.tr.md +1 -1
  8. package/bin/codegen +5 -2
  9. package/bin/tezgah-design +288 -1
  10. package/bin/tezgah-docs +1 -1
  11. package/bin/tezgah-doctor +39 -2
  12. package/bin/tezgah-import-ai-research +26 -12
  13. package/bin/tezgah-ocr-review +296 -0
  14. package/bin/tezgah-research +255 -44
  15. package/bin/tezgah-review-quote +185 -0
  16. package/bin/tezgah-setup +1365 -55
  17. package/bin/tezgah-skill +1 -1
  18. package/bin/tezgah-status +201 -3
  19. package/bin/tezgah-task +258 -20
  20. package/commands/adhd.md +2 -2
  21. package/commands/ponytail.md +4 -4
  22. package/docs/architecture.md +2 -2
  23. package/docs/citations-baseline.json +3 -3
  24. package/docs/clarity-baseline.json +11 -5
  25. package/docs/contract.md +91 -43
  26. package/docs/environment.md +5 -2
  27. package/docs/evidence.md +109 -17
  28. package/docs/feature-audit.md +2 -2
  29. package/docs/gate.md +60 -33
  30. package/docs/glossary.md +6 -3
  31. package/docs/hosts.md +313 -17
  32. package/docs/index.json +34 -8
  33. package/docs/judge.md +4 -2
  34. package/docs/layers.md +4 -4
  35. package/docs/models.md +37 -9
  36. package/docs/operations.md +141 -5
  37. package/docs/research.md +815 -8
  38. package/docs/skills.md +137 -13
  39. package/docs/status-line.md +121 -1
  40. package/docs/testing.md +3 -1
  41. package/hooks/hooks.json +2 -2
  42. package/hooks/lang/en.json +21 -0
  43. package/hooks/lang/tr.json +22 -1
  44. package/hooks/projects-stop.py +2 -1
  45. package/hooks/tezgah_agents.py +65 -25
  46. package/hooks/tezgah_apps.py +18 -0
  47. package/hooks/tezgah_attest.py +79 -10
  48. package/hooks/tezgah_components.py +4 -0
  49. package/hooks/tezgah_context.py +207 -55
  50. package/hooks/tezgah_extension.py +337 -0
  51. package/hooks/tezgah_gate.py +267 -79
  52. package/hooks/tezgah_integrity.py +864 -143
  53. package/hooks/tezgah_judge.py +6 -4
  54. package/hooks/tezgah_models.py +59 -19
  55. package/hooks/tezgah_orca.py +1 -1
  56. package/hooks/tezgah_paths.py +145 -8
  57. package/hooks/tezgah_policy.py +128 -57
  58. package/hooks/tezgah_rank.py +9 -6
  59. package/hooks/tezgah_replay.py +12 -6
  60. package/hooks/tezgah_research.py +1414 -76
  61. package/hooks/tezgah_research_capture.py +1544 -0
  62. package/hooks/tezgah_research_evidence.py +842 -0
  63. package/hooks/tezgah_research_stats.py +258 -0
  64. package/hooks/tezgah_skill_pick.py +408 -106
  65. package/hooks/tezgah_stack.py +236 -1
  66. package/hooks/tezgah_stopspec.py +8 -1
  67. package/hooks/tezgah_task.py +14 -5
  68. package/hooks/tezgah_taste_ledger.py +1 -1
  69. package/hosts/amp/hook.py +217 -0
  70. package/hosts/amp/tezgah-plugin.ts.in +386 -0
  71. package/hosts/antigravity/hook.py +255 -0
  72. package/hosts/codex/hook.py +2 -1
  73. package/hosts/commandcode/hook.py +239 -0
  74. package/hosts/copilot/hook.py +284 -0
  75. package/hosts/cursor/hook.py +31 -3
  76. package/hosts/droid/hook.py +259 -0
  77. package/hosts/dsh/hooks.json +1 -1
  78. package/hosts/gemini/hook.py +213 -0
  79. package/hosts/goose/hook.py +145 -0
  80. package/hosts/omp/hook.py +4 -1
  81. package/hosts/omp/tezgah-hook.ts.in +30 -7
  82. package/hosts/opencode/plugins/tezgah.js +309 -48
  83. package/hosts/opencode/tui/cli/tui.tsx +100 -0
  84. package/hosts/opencode/tui/tezgah-tui.tsx +2 -1
  85. package/hosts/qwen/hook.py +206 -0
  86. package/output-styles/tezgah.md +20 -15
  87. package/package.json +1 -1
  88. package/skills/ai-research/SKILL.md +21 -14
  89. package/skills/ai-research/SOURCE +7 -5
  90. package/skills/ai-research/index/1-frame.md +6 -6
  91. package/skills/ai-research/index/2-data.md +9 -9
  92. package/skills/ai-research/index/3-train.md +36 -36
  93. package/skills/ai-research/index/4-measure.md +13 -13
  94. package/skills/ai-research/index/5-run.md +11 -11
  95. package/skills/ai-research/index/6-write.md +23 -23
  96. package/skills/ai-research/library.json +104 -104
  97. package/skills/analyze-app/SKILL.md +17 -12
  98. package/skills/design-contract/SKILL.md +42 -7
  99. package/skills/design-contract/data-views.md +2 -2
  100. package/skills/design-library/EVALS.md +1 -1
  101. package/skills/design-library/INDEX.md +39 -39
  102. package/skills/design-library/SKILL.md +19 -13
  103. package/skills/design-library/SOURCE +45 -43
  104. package/skills/feature-audit/SKILL.md +20 -14
  105. package/skills/hallmark/LICENSE +21 -0
  106. package/skills/hallmark/SKILL.md +154 -0
  107. package/skills/hallmark/SOURCE +207 -0
  108. package/skills/hallmark/agents/openai.yaml +13 -0
  109. package/skills/hallmark/references/anatomy.md +71 -0
  110. package/skills/hallmark/references/brand-truth.md +40 -0
  111. package/skills/hallmark/references/comp.md +104 -0
  112. package/skills/hallmark/references/craft.md +116 -0
  113. package/skills/hallmark/references/dark-mode.md +49 -0
  114. package/skills/hallmark/references/data-viz.md +48 -0
  115. package/skills/hallmark/references/design-md.md +53 -0
  116. package/skills/hallmark/references/direction.md +52 -0
  117. package/skills/hallmark/references/export-formats.md +80 -0
  118. package/skills/hallmark/references/flow.md +131 -0
  119. package/skills/hallmark/references/fonts.md +58 -0
  120. package/skills/hallmark/references/gates.md +243 -0
  121. package/skills/hallmark/references/genres/atmospheric.md +24 -0
  122. package/skills/hallmark/references/genres/editorial.md +24 -0
  123. package/skills/hallmark/references/genres/modern-minimal.md +24 -0
  124. package/skills/hallmark/references/genres/playful.md +24 -0
  125. package/skills/hallmark/references/imagery.md +99 -0
  126. package/skills/hallmark/references/interaction-and-states.md +89 -0
  127. package/skills/hallmark/references/layout.md +104 -0
  128. package/skills/hallmark/references/macrostructures/01-bento-grid.md +21 -0
  129. package/skills/hallmark/references/macrostructures/02-long-document.md +35 -0
  130. package/skills/hallmark/references/macrostructures/03-marquee-hero.md +21 -0
  131. package/skills/hallmark/references/macrostructures/04-stat-led.md +21 -0
  132. package/skills/hallmark/references/macrostructures/05-workbench.md +21 -0
  133. package/skills/hallmark/references/macrostructures/06-conversational-faq.md +28 -0
  134. package/skills/hallmark/references/macrostructures/07-manifesto.md +28 -0
  135. package/skills/hallmark/references/macrostructures/08-photographic.md +21 -0
  136. package/skills/hallmark/references/macrostructures/09-quote-led.md +21 -0
  137. package/skills/hallmark/references/macrostructures/10-specimen.md +21 -0
  138. package/skills/hallmark/references/macrostructures/11-catalogue.md +21 -0
  139. package/skills/hallmark/references/macrostructures/12-letter.md +21 -0
  140. package/skills/hallmark/references/macrostructures/13-index-first.md +21 -0
  141. package/skills/hallmark/references/macrostructures/14-narrative-workflow.md +21 -0
  142. package/skills/hallmark/references/macrostructures/15-split-studio.md +21 -0
  143. package/skills/hallmark/references/macrostructures/16-feature-stack.md +23 -0
  144. package/skills/hallmark/references/macrostructures/17-type-specimen.md +19 -0
  145. package/skills/hallmark/references/macrostructures/18-portfolio-grid.md +21 -0
  146. package/skills/hallmark/references/macrostructures/19-map-diagram.md +21 -0
  147. package/skills/hallmark/references/macrostructures/20-ecosystem-index.md +19 -0
  148. package/skills/hallmark/references/macrostructures/21-component-playground.md +21 -0
  149. package/skills/hallmark/references/macrostructures.md +119 -0
  150. package/skills/hallmark/references/microinteractions.md +80 -0
  151. package/skills/hallmark/references/patterns.md +202 -0
  152. package/skills/hallmark/references/redesign.md +88 -0
  153. package/skills/hallmark/references/responsive.md +46 -0
  154. package/skills/hallmark/references/review-finish.md +19 -0
  155. package/skills/hallmark/references/review-hero.md +20 -0
  156. package/skills/hallmark/references/scroll-choreography.md +110 -0
  157. package/skills/hallmark/references/texture.md +90 -0
  158. package/skills/hallmark/references/themes/README.md +58 -0
  159. package/skills/hallmark/references/themes/almanac.jpg +0 -0
  160. package/skills/hallmark/references/themes/almanac.md +103 -0
  161. package/skills/hallmark/references/themes/arcade.jpg +0 -0
  162. package/skills/hallmark/references/themes/arcade.md +102 -0
  163. package/skills/hallmark/references/themes/atelier.jpg +0 -0
  164. package/skills/hallmark/references/themes/atelier.md +98 -0
  165. package/skills/hallmark/references/themes/bloom.jpg +0 -0
  166. package/skills/hallmark/references/themes/bloom.md +102 -0
  167. package/skills/hallmark/references/themes/blueprint.jpg +0 -0
  168. package/skills/hallmark/references/themes/blueprint.md +103 -0
  169. package/skills/hallmark/references/themes/broadside.jpg +0 -0
  170. package/skills/hallmark/references/themes/broadside.md +108 -0
  171. package/skills/hallmark/references/themes/brutal.jpg +0 -0
  172. package/skills/hallmark/references/themes/brutal.md +95 -0
  173. package/skills/hallmark/references/themes/carnival.jpg +0 -0
  174. package/skills/hallmark/references/themes/carnival.md +104 -0
  175. package/skills/hallmark/references/themes/cobalt.jpg +0 -0
  176. package/skills/hallmark/references/themes/cobalt.md +101 -0
  177. package/skills/hallmark/references/themes/console.jpg +0 -0
  178. package/skills/hallmark/references/themes/console.md +99 -0
  179. package/skills/hallmark/references/themes/coral.jpg +0 -0
  180. package/skills/hallmark/references/themes/coral.md +102 -0
  181. package/skills/hallmark/references/themes/darkroom.jpg +0 -0
  182. package/skills/hallmark/references/themes/darkroom.md +99 -0
  183. package/skills/hallmark/references/themes/dose.jpg +0 -0
  184. package/skills/hallmark/references/themes/dose.md +98 -0
  185. package/skills/hallmark/references/themes/dusk.jpg +0 -0
  186. package/skills/hallmark/references/themes/dusk.md +105 -0
  187. package/skills/hallmark/references/themes/editorial.jpg +0 -0
  188. package/skills/hallmark/references/themes/editorial.md +97 -0
  189. package/skills/hallmark/references/themes/formwork.jpg +0 -0
  190. package/skills/hallmark/references/themes/formwork.md +107 -0
  191. package/skills/hallmark/references/themes/furrow.jpg +0 -0
  192. package/skills/hallmark/references/themes/furrow.md +98 -0
  193. package/skills/hallmark/references/themes/garden.jpg +0 -0
  194. package/skills/hallmark/references/themes/garden.md +97 -0
  195. package/skills/hallmark/references/themes/grid.jpg +0 -0
  196. package/skills/hallmark/references/themes/grid.md +101 -0
  197. package/skills/hallmark/references/themes/halftone.jpg +0 -0
  198. package/skills/hallmark/references/themes/halftone.md +106 -0
  199. package/skills/hallmark/references/themes/hedgerow.jpg +0 -0
  200. package/skills/hallmark/references/themes/hedgerow.md +104 -0
  201. package/skills/hallmark/references/themes/hum.jpg +0 -0
  202. package/skills/hallmark/references/themes/hum.md +99 -0
  203. package/skills/hallmark/references/themes/index.json +2191 -0
  204. package/skills/hallmark/references/themes/ink.jpg +0 -0
  205. package/skills/hallmark/references/themes/ink.md +106 -0
  206. package/skills/hallmark/references/themes/kiln.jpg +0 -0
  207. package/skills/hallmark/references/themes/kiln.md +108 -0
  208. package/skills/hallmark/references/themes/loom.jpg +0 -0
  209. package/skills/hallmark/references/themes/loom.md +106 -0
  210. package/skills/hallmark/references/themes/lumen.jpg +0 -0
  211. package/skills/hallmark/references/themes/lumen.md +103 -0
  212. package/skills/hallmark/references/themes/manifesto.jpg +0 -0
  213. package/skills/hallmark/references/themes/manifesto.md +101 -0
  214. package/skills/hallmark/references/themes/midnight.jpg +0 -0
  215. package/skills/hallmark/references/themes/midnight.md +97 -0
  216. package/skills/hallmark/references/themes/newsprint.jpg +0 -0
  217. package/skills/hallmark/references/themes/newsprint.md +102 -0
  218. package/skills/hallmark/references/themes/overprint.jpg +0 -0
  219. package/skills/hallmark/references/themes/overprint.md +101 -0
  220. package/skills/hallmark/references/themes/pasteup.jpg +0 -0
  221. package/skills/hallmark/references/themes/pasteup.md +100 -0
  222. package/skills/hallmark/references/themes/piste.jpg +0 -0
  223. package/skills/hallmark/references/themes/piste.md +99 -0
  224. package/skills/hallmark/references/themes/ribbon.jpg +0 -0
  225. package/skills/hallmark/references/themes/ribbon.md +106 -0
  226. package/skills/hallmark/references/themes/rim.jpg +0 -0
  227. package/skills/hallmark/references/themes/rim.md +96 -0
  228. package/skills/hallmark/references/themes/riso.jpg +0 -0
  229. package/skills/hallmark/references/themes/riso.md +98 -0
  230. package/skills/hallmark/references/themes/solstice.jpg +0 -0
  231. package/skills/hallmark/references/themes/solstice.md +104 -0
  232. package/skills/hallmark/references/themes/specimen.jpg +0 -0
  233. package/skills/hallmark/references/themes/specimen.md +100 -0
  234. package/skills/hallmark/references/themes/spore.jpg +0 -0
  235. package/skills/hallmark/references/themes/spore.md +104 -0
  236. package/skills/hallmark/references/themes/sport.jpg +0 -0
  237. package/skills/hallmark/references/themes/sport.md +101 -0
  238. package/skills/hallmark/references/themes/squeeze.jpg +0 -0
  239. package/skills/hallmark/references/themes/squeeze.md +99 -0
  240. package/skills/hallmark/references/themes/sticker.jpg +0 -0
  241. package/skills/hallmark/references/themes/sticker.md +106 -0
  242. package/skills/hallmark/references/themes/studio.jpg +0 -0
  243. package/skills/hallmark/references/themes/studio.md +99 -0
  244. package/skills/hallmark/references/themes/terminal.jpg +0 -0
  245. package/skills/hallmark/references/themes/terminal.md +96 -0
  246. package/skills/hallmark/references/themes/terrazzo.jpg +0 -0
  247. package/skills/hallmark/references/themes/terrazzo.md +106 -0
  248. package/skills/hallmark/references/themes/ultraviolet.jpg +0 -0
  249. package/skills/hallmark/references/themes/ultraviolet.md +106 -0
  250. package/skills/hallmark/references/themes/vellum.jpg +0 -0
  251. package/skills/hallmark/references/themes/vellum.md +105 -0
  252. package/skills/hallmark/references/themes/wayfind.jpg +0 -0
  253. package/skills/hallmark/references/themes/wayfind.md +105 -0
  254. package/skills/hallmark/references/themes/werkstatt.jpg +0 -0
  255. package/skills/hallmark/references/themes/werkstatt.md +105 -0
  256. package/skills/hallmark/scripts/capture.mjs +125 -0
  257. package/skills/hallmark/scripts/fixtures/exemplars/atmospheric.html +324 -0
  258. package/skills/hallmark/scripts/fixtures/exemplars/editorial.html +363 -0
  259. package/skills/hallmark/scripts/fixtures/exemplars/modern-minimal.html +527 -0
  260. package/skills/hallmark/scripts/fixtures/exemplars/playful.html +353 -0
  261. package/skills/hallmark/scripts/fixtures/eyebrow-fail/index.html +118 -0
  262. package/skills/hallmark/scripts/fixtures/eyebrow-pass/index.html +137 -0
  263. package/skills/hallmark/scripts/fixtures/kept/after.html +27 -0
  264. package/skills/hallmark/scripts/fixtures/kept/before.html +26 -0
  265. package/skills/hallmark/scripts/fixtures/slopped.html +145 -0
  266. package/skills/hallmark/scripts/fixtures/tailwind-fail/Feature.vue +14 -0
  267. package/skills/hallmark/scripts/fixtures/tailwind-fail/Hero.tsx +33 -0
  268. package/skills/hallmark/scripts/fixtures/tailwind-fail/index.html +74 -0
  269. package/skills/hallmark/scripts/fixtures/tailwind-fail/style.css +6 -0
  270. package/skills/hallmark/scripts/fixtures/tailwind-pass/Hero.tsx +33 -0
  271. package/skills/hallmark/scripts/fixtures/tailwind-pass/index.html +57 -0
  272. package/skills/hallmark/scripts/fixtures/tailwind-pass/style.css +15 -0
  273. package/skills/hallmark/scripts/fixtures/waiver/index.html +54 -0
  274. package/skills/hallmark/scripts/imagegen.mjs +85 -0
  275. package/skills/hallmark/scripts/install-hook.mjs +70 -0
  276. package/skills/hallmark/scripts/install.mjs +141 -0
  277. package/skills/hallmark/scripts/lint-hook.mjs +49 -0
  278. package/skills/hallmark/scripts/photos.json +1 -0
  279. package/skills/hallmark/scripts/picture.mjs +71 -0
  280. package/skills/hallmark/scripts/refcheck.mjs +352 -0
  281. package/skills/hallmark/scripts/sloplint-render.mjs +1252 -0
  282. package/skills/hallmark/scripts/sloplint.mjs +4422 -0
  283. package/skills/hallmark/scripts/theme.mjs +650 -0
  284. package/skills/harness/SKILL.md +17 -8
  285. package/skills/i-have-adhd/SKILL.md +32 -12
  286. package/skills/no-ai-slop/SKILL.md +15 -1
  287. package/skills/plan-add/SKILL.md +38 -27
  288. package/skills/plan-status/SKILL.md +13 -7
  289. package/skills/plan-sync/SKILL.md +18 -12
  290. package/skills/pm-frameworks/SKILL.md +19 -10
  291. package/skills/pm-frameworks/SOURCE +7 -5
  292. package/skills/ponytail/SKILL.md +19 -14
  293. package/skills/product-analysis/SKILL.md +22 -18
  294. package/skills/product-flow/SKILL.md +14 -9
  295. package/skills/research/SKILL.md +97 -48
  296. package/skills/rl-env/SKILL.md +25 -13
  297. package/skills/rl-env/SOURCE +8 -6
  298. package/skills/tezgah-contract/SKILL.md +65 -47
  299. package/statusline.py +20 -9
  300. package/workflows/graph-review.js +14 -2
  301. package/bin/tezgah-skill.py +0 -59
  302. /package/skills/ai-research/0-autoresearch-skill/{SKILL.md → ENTRY.md} +0 -0
  303. /package/skills/ai-research/01-model-architecture/litgpt/{SKILL.md → ENTRY.md} +0 -0
  304. /package/skills/ai-research/01-model-architecture/mamba/{SKILL.md → ENTRY.md} +0 -0
  305. /package/skills/ai-research/01-model-architecture/nanogpt/{SKILL.md → ENTRY.md} +0 -0
  306. /package/skills/ai-research/01-model-architecture/rwkv/{SKILL.md → ENTRY.md} +0 -0
  307. /package/skills/ai-research/01-model-architecture/torchtitan/{SKILL.md → ENTRY.md} +0 -0
  308. /package/skills/ai-research/02-tokenization/huggingface-tokenizers/{SKILL.md → ENTRY.md} +0 -0
  309. /package/skills/ai-research/02-tokenization/sentencepiece/{SKILL.md → ENTRY.md} +0 -0
  310. /package/skills/ai-research/03-fine-tuning/axolotl/{SKILL.md → ENTRY.md} +0 -0
  311. /package/skills/ai-research/03-fine-tuning/llama-factory/{SKILL.md → ENTRY.md} +0 -0
  312. /package/skills/ai-research/03-fine-tuning/peft/{SKILL.md → ENTRY.md} +0 -0
  313. /package/skills/ai-research/03-fine-tuning/unsloth/{SKILL.md → ENTRY.md} +0 -0
  314. /package/skills/ai-research/04-mechanistic-interpretability/nnsight/{SKILL.md → ENTRY.md} +0 -0
  315. /package/skills/ai-research/04-mechanistic-interpretability/pyvene/{SKILL.md → ENTRY.md} +0 -0
  316. /package/skills/ai-research/04-mechanistic-interpretability/saelens/{SKILL.md → ENTRY.md} +0 -0
  317. /package/skills/ai-research/04-mechanistic-interpretability/transformer-lens/{SKILL.md → ENTRY.md} +0 -0
  318. /package/skills/ai-research/05-data-processing/nemo-curator/{SKILL.md → ENTRY.md} +0 -0
  319. /package/skills/ai-research/05-data-processing/ray-data/{SKILL.md → ENTRY.md} +0 -0
  320. /package/skills/ai-research/06-post-training/grpo-rl-training/{SKILL.md → ENTRY.md} +0 -0
  321. /package/skills/ai-research/06-post-training/miles/{SKILL.md → ENTRY.md} +0 -0
  322. /package/skills/ai-research/06-post-training/openrlhf/{SKILL.md → ENTRY.md} +0 -0
  323. /package/skills/ai-research/06-post-training/simpo/{SKILL.md → ENTRY.md} +0 -0
  324. /package/skills/ai-research/06-post-training/slime/{SKILL.md → ENTRY.md} +0 -0
  325. /package/skills/ai-research/06-post-training/torchforge/{SKILL.md → ENTRY.md} +0 -0
  326. /package/skills/ai-research/06-post-training/trl-fine-tuning/{SKILL.md → ENTRY.md} +0 -0
  327. /package/skills/ai-research/06-post-training/verl/{SKILL.md → ENTRY.md} +0 -0
  328. /package/skills/ai-research/07-safety-alignment/constitutional-ai/{SKILL.md → ENTRY.md} +0 -0
  329. /package/skills/ai-research/07-safety-alignment/llamaguard/{SKILL.md → ENTRY.md} +0 -0
  330. /package/skills/ai-research/07-safety-alignment/nemo-guardrails/{SKILL.md → ENTRY.md} +0 -0
  331. /package/skills/ai-research/07-safety-alignment/prompt-guard/{SKILL.md → ENTRY.md} +0 -0
  332. /package/skills/ai-research/08-distributed-training/accelerate/{SKILL.md → ENTRY.md} +0 -0
  333. /package/skills/ai-research/08-distributed-training/deepspeed/{SKILL.md → ENTRY.md} +0 -0
  334. /package/skills/ai-research/08-distributed-training/megatron-core/{SKILL.md → ENTRY.md} +0 -0
  335. /package/skills/ai-research/08-distributed-training/pytorch-fsdp2/{SKILL.md → ENTRY.md} +0 -0
  336. /package/skills/ai-research/08-distributed-training/pytorch-lightning/{SKILL.md → ENTRY.md} +0 -0
  337. /package/skills/ai-research/08-distributed-training/ray-train/{SKILL.md → ENTRY.md} +0 -0
  338. /package/skills/ai-research/09-infrastructure/lambda-labs/{SKILL.md → ENTRY.md} +0 -0
  339. /package/skills/ai-research/09-infrastructure/modal/{SKILL.md → ENTRY.md} +0 -0
  340. /package/skills/ai-research/09-infrastructure/skypilot/{SKILL.md → ENTRY.md} +0 -0
  341. /package/skills/ai-research/10-optimization/awq/{SKILL.md → ENTRY.md} +0 -0
  342. /package/skills/ai-research/10-optimization/bitsandbytes/{SKILL.md → ENTRY.md} +0 -0
  343. /package/skills/ai-research/10-optimization/flash-attention/{SKILL.md → ENTRY.md} +0 -0
  344. /package/skills/ai-research/10-optimization/gguf/{SKILL.md → ENTRY.md} +0 -0
  345. /package/skills/ai-research/10-optimization/gptq/{SKILL.md → ENTRY.md} +0 -0
  346. /package/skills/ai-research/10-optimization/hqq/{SKILL.md → ENTRY.md} +0 -0
  347. /package/skills/ai-research/10-optimization/ml-training-recipes/{SKILL.md → ENTRY.md} +0 -0
  348. /package/skills/ai-research/11-evaluation/bigcode-evaluation-harness/{SKILL.md → ENTRY.md} +0 -0
  349. /package/skills/ai-research/11-evaluation/lm-evaluation-harness/{SKILL.md → ENTRY.md} +0 -0
  350. /package/skills/ai-research/11-evaluation/nemo-evaluator/{SKILL.md → ENTRY.md} +0 -0
  351. /package/skills/ai-research/12-inference-serving/llama-cpp/{SKILL.md → ENTRY.md} +0 -0
  352. /package/skills/ai-research/12-inference-serving/sglang/{SKILL.md → ENTRY.md} +0 -0
  353. /package/skills/ai-research/12-inference-serving/tensorrt-llm/{SKILL.md → ENTRY.md} +0 -0
  354. /package/skills/ai-research/12-inference-serving/vllm/{SKILL.md → ENTRY.md} +0 -0
  355. /package/skills/ai-research/13-mlops/mlflow/{SKILL.md → ENTRY.md} +0 -0
  356. /package/skills/ai-research/13-mlops/swanlab/{SKILL.md → ENTRY.md} +0 -0
  357. /package/skills/ai-research/13-mlops/tensorboard/{SKILL.md → ENTRY.md} +0 -0
  358. /package/skills/ai-research/13-mlops/weights-and-biases/{SKILL.md → ENTRY.md} +0 -0
  359. /package/skills/ai-research/14-agents/a-evolve/{SKILL.md → ENTRY.md} +0 -0
  360. /package/skills/ai-research/14-agents/autogpt/{SKILL.md → ENTRY.md} +0 -0
  361. /package/skills/ai-research/14-agents/crewai/{SKILL.md → ENTRY.md} +0 -0
  362. /package/skills/ai-research/14-agents/langchain/{SKILL.md → ENTRY.md} +0 -0
  363. /package/skills/ai-research/14-agents/llamaindex/{SKILL.md → ENTRY.md} +0 -0
  364. /package/skills/ai-research/15-rag/chroma/{SKILL.md → ENTRY.md} +0 -0
  365. /package/skills/ai-research/15-rag/faiss/{SKILL.md → ENTRY.md} +0 -0
  366. /package/skills/ai-research/15-rag/pinecone/{SKILL.md → ENTRY.md} +0 -0
  367. /package/skills/ai-research/15-rag/qdrant/{SKILL.md → ENTRY.md} +0 -0
  368. /package/skills/ai-research/15-rag/sentence-transformers/{SKILL.md → ENTRY.md} +0 -0
  369. /package/skills/ai-research/16-prompt-engineering/dspy/{SKILL.md → ENTRY.md} +0 -0
  370. /package/skills/ai-research/16-prompt-engineering/guidance/{SKILL.md → ENTRY.md} +0 -0
  371. /package/skills/ai-research/16-prompt-engineering/instructor/{SKILL.md → ENTRY.md} +0 -0
  372. /package/skills/ai-research/16-prompt-engineering/outlines/{SKILL.md → ENTRY.md} +0 -0
  373. /package/skills/ai-research/17-observability/langsmith/{SKILL.md → ENTRY.md} +0 -0
  374. /package/skills/ai-research/17-observability/phoenix/{SKILL.md → ENTRY.md} +0 -0
  375. /package/skills/ai-research/18-multimodal/audiocraft/{SKILL.md → ENTRY.md} +0 -0
  376. /package/skills/ai-research/18-multimodal/blip-2/{SKILL.md → ENTRY.md} +0 -0
  377. /package/skills/ai-research/18-multimodal/clip/{SKILL.md → ENTRY.md} +0 -0
  378. /package/skills/ai-research/18-multimodal/cosmos-policy/{SKILL.md → ENTRY.md} +0 -0
  379. /package/skills/ai-research/18-multimodal/llava/{SKILL.md → ENTRY.md} +0 -0
  380. /package/skills/ai-research/18-multimodal/openpi/{SKILL.md → ENTRY.md} +0 -0
  381. /package/skills/ai-research/18-multimodal/openvla-oft/{SKILL.md → ENTRY.md} +0 -0
  382. /package/skills/ai-research/18-multimodal/segment-anything/{SKILL.md → ENTRY.md} +0 -0
  383. /package/skills/ai-research/18-multimodal/stable-diffusion/{SKILL.md → ENTRY.md} +0 -0
  384. /package/skills/ai-research/18-multimodal/whisper/{SKILL.md → ENTRY.md} +0 -0
  385. /package/skills/ai-research/19-emerging-techniques/knowledge-distillation/{SKILL.md → ENTRY.md} +0 -0
  386. /package/skills/ai-research/19-emerging-techniques/long-context/{SKILL.md → ENTRY.md} +0 -0
  387. /package/skills/ai-research/19-emerging-techniques/model-merging/{SKILL.md → ENTRY.md} +0 -0
  388. /package/skills/ai-research/19-emerging-techniques/model-pruning/{SKILL.md → ENTRY.md} +0 -0
  389. /package/skills/ai-research/19-emerging-techniques/moe-training/{SKILL.md → ENTRY.md} +0 -0
  390. /package/skills/ai-research/19-emerging-techniques/speculative-decoding/{SKILL.md → ENTRY.md} +0 -0
  391. /package/skills/ai-research/20-ml-paper-writing/academic-plotting/{SKILL.md → ENTRY.md} +0 -0
  392. /package/skills/ai-research/20-ml-paper-writing/ml-paper-writing/{SKILL.md → ENTRY.md} +0 -0
  393. /package/skills/ai-research/20-ml-paper-writing/presenting-conference-talks/{SKILL.md → ENTRY.md} +0 -0
  394. /package/skills/ai-research/20-ml-paper-writing/systems-paper-writing/{SKILL.md → ENTRY.md} +0 -0
  395. /package/skills/ai-research/21-research-ideation/brainstorming-research-ideas/{SKILL.md → ENTRY.md} +0 -0
  396. /package/skills/ai-research/21-research-ideation/creative-thinking-for-research/{SKILL.md → ENTRY.md} +0 -0
  397. /package/skills/ai-research/22-agent-native-research-artifact/compiler/{SKILL.md → ENTRY.md} +0 -0
  398. /package/skills/ai-research/22-agent-native-research-artifact/research-manager/{SKILL.md → ENTRY.md} +0 -0
  399. /package/skills/ai-research/22-agent-native-research-artifact/rigor-reviewer/{SKILL.md → ENTRY.md} +0 -0
  400. /package/skills/design-library/adaptive-interfaces/skills/colour-independence/{SKILL.md → ENTRY.md} +0 -0
  401. /package/skills/design-library/adaptive-interfaces/skills/flexible-typography/{SKILL.md → ENTRY.md} +0 -0
  402. /package/skills/design-library/adaptive-interfaces/skills/information-density/{SKILL.md → ENTRY.md} +0 -0
  403. /package/skills/design-library/adaptive-interfaces/skills/preference-audit/{SKILL.md → ENTRY.md} +0 -0
  404. /package/skills/design-library/adaptive-interfaces/skills/responsive-accessibility/{SKILL.md → ENTRY.md} +0 -0
  405. /package/skills/design-library/adaptive-interfaces/skills/responsive-review/{SKILL.md → ENTRY.md} +0 -0
  406. /package/skills/design-library/adaptive-interfaces/skills/simplified-views/{SKILL.md → ENTRY.md} +0 -0
  407. /package/skills/design-library/adaptive-interfaces/skills/specify/{SKILL.md → ENTRY.md} +0 -0
  408. /package/skills/design-library/adaptive-interfaces/skills/user-preference-respect/{SKILL.md → ENTRY.md} +0 -0
  409. /package/skills/design-library/cognitive-accessibility/skills/adaptive-personalisation/{SKILL.md → ENTRY.md} +0 -0
  410. /package/skills/design-library/cognitive-accessibility/skills/assess-load/{SKILL.md → ENTRY.md} +0 -0
  411. /package/skills/design-library/cognitive-accessibility/skills/cognitive-load-assessment/{SKILL.md → ENTRY.md} +0 -0
  412. /package/skills/design-library/cognitive-accessibility/skills/contextual-help-design/{SKILL.md → ENTRY.md} +0 -0
  413. /package/skills/design-library/cognitive-accessibility/skills/error-prevention-recovery/{SKILL.md → ENTRY.md} +0 -0
  414. /package/skills/design-library/cognitive-accessibility/skills/focus-attention-design/{SKILL.md → ENTRY.md} +0 -0
  415. /package/skills/design-library/cognitive-accessibility/skills/memory-load-reduction/{SKILL.md → ENTRY.md} +0 -0
  416. /package/skills/design-library/cognitive-accessibility/skills/plain-language-design/{SKILL.md → ENTRY.md} +0 -0
  417. /package/skills/design-library/cognitive-accessibility/skills/review/{SKILL.md → ENTRY.md} +0 -0
  418. /package/skills/design-library/cognitive-accessibility/skills/simplify/{SKILL.md → ENTRY.md} +0 -0
  419. /package/skills/design-library/cognitive-accessibility/skills/wayfinding-navigation/{SKILL.md → ENTRY.md} +0 -0
  420. /package/skills/design-library/design-research/skills/behavioural-analytics/{SKILL.md → ENTRY.md} +0 -0
  421. /package/skills/design-library/design-research/skills/qual-quant-triangulation/{SKILL.md → ENTRY.md} +0 -0
  422. /package/skills/design-library/inclusive-interaction/skills/audit/{SKILL.md → ENTRY.md} +0 -0
  423. /package/skills/design-library/inclusive-interaction/skills/design-flow/{SKILL.md → ENTRY.md} +0 -0
  424. /package/skills/design-library/inclusive-interaction/skills/feedback-and-status/{SKILL.md → ENTRY.md} +0 -0
  425. /package/skills/design-library/inclusive-interaction/skills/gesture-alternatives/{SKILL.md → ENTRY.md} +0 -0
  426. /package/skills/design-library/inclusive-interaction/skills/keyboard-navigation/{SKILL.md → ENTRY.md} +0 -0
  427. /package/skills/design-library/inclusive-interaction/skills/keyboard-review/{SKILL.md → ENTRY.md} +0 -0
  428. /package/skills/design-library/inclusive-interaction/skills/motion-sensitivity/{SKILL.md → ENTRY.md} +0 -0
  429. /package/skills/design-library/inclusive-interaction/skills/multi-modal-input/{SKILL.md → ENTRY.md} +0 -0
  430. /package/skills/design-library/inclusive-interaction/skills/touch-target-design/{SKILL.md → ENTRY.md} +0 -0
  431. /package/skills/design-library/inclusive-interaction/skills/voice-interaction/{SKILL.md → ENTRY.md} +0 -0
  432. /package/skills/design-library/visual-critique/skills/critique-affordance/{SKILL.md → ENTRY.md} +0 -0
  433. /package/skills/design-library/visual-critique/skills/critique-brand-consistency/{SKILL.md → ENTRY.md} +0 -0
  434. /package/skills/design-library/visual-critique/skills/critique-color/{SKILL.md → ENTRY.md} +0 -0
  435. /package/skills/design-library/visual-critique/skills/critique-composition/{SKILL.md → ENTRY.md} +0 -0
  436. /package/skills/design-library/visual-critique/skills/critique-information-density/{SKILL.md → ENTRY.md} +0 -0
  437. /package/skills/design-library/visual-critique/skills/critique-typography/{SKILL.md → ENTRY.md} +0 -0
  438. /package/skills/design-library/visual-critique/skills/critique-visual-hierarchy/{SKILL.md → ENTRY.md} +0 -0
  439. /package/skills/pm-frameworks/intended-vs-implemented/{SKILL.md → ENTRY.md} +0 -0
  440. /package/skills/pm-frameworks/opportunity-solution-tree/{SKILL.md → ENTRY.md} +0 -0
  441. /package/skills/rl-env/generate-nemo-gym-env/{SKILL.md → ENTRY.md} +0 -0
  442. /package/skills/rl-env/generate-openenv-env/{SKILL.md → ENTRY.md} +0 -0
  443. /package/skills/rl-env/generate-ors-env/{SKILL.md → ENTRY.md} +0 -0
  444. /package/skills/rl-env/generate-verifiers-env/{SKILL.md → ENTRY.md} +0 -0
  445. /package/skills/rl-env/rl-env-from-description/{SKILL.md → ENTRY.md} +0 -0
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
@@ -188,7 +210,7 @@ LOCAL_BIN = os.path.join(HOME, ".local", "bin")
188
210
  SKILLS = ["harness", "tezgah-contract", "ponytail", "i-have-adhd", "no-ai-slop",
189
211
  "analyze-app", "plan-add", "plan-status", "plan-sync", "research",
190
212
  "product-analysis", "product-flow", "feature-audit", "design-contract",
191
- "design-library", "pm-frameworks", "ai-research", "rl-env"]
213
+ "design-library", "hallmark", "pm-frameworks", "ai-research", "rl-env"]
192
214
 
193
215
 
194
216
  def skills_linked(base):
@@ -209,8 +231,22 @@ RESEARCH_SKILLS = frozenset(("ai-research", "rl-env"))
209
231
  # explicit value wins.
210
232
  OPENCODE_IGNORE = [".git/**", "node_modules/**", "dist/**", "build/**",
211
233
  ".venv/**", "__pycache__/**", ".ruff_cache/**"]
234
+
235
+
236
+ def opencode_v2():
237
+ """opencode 2 keeps its terminal settings in cli.json (it migrates tui.json
238
+ into it once, on first start) and ignores tui.json afterwards."""
239
+ return os.path.isfile(os.path.join(OPENCODE, "cli.json"))
240
+
241
+
242
+ # The opencode 2 status line: a link to the plugin directory hosts/opencode/tui/cli,
243
+ # listed in cli.json `plugins` as file://<this path>.
244
+ OPENCODE_CLI_PLUGIN = os.path.join(OPENCODE, "tui-plugins", "tezgah-cli")
245
+
246
+
212
247
  WORKFLOW_FILES = ["graph-map.js", "graph-review.js", "graph-impact.js"]
213
- 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"]
214
250
  # orx ships an installable skill shim for four agents; dsh has no orx harness,
215
251
  # so research routing there falls back to `orx skill` on the shell. omp has no
216
252
  # orx target of its own, and needs none: orx's `codex` target writes its shim to
@@ -231,7 +267,8 @@ MATERIALISED = os.path.join(tp.CONFIG_DIR, "materialised")
231
267
  # copies die with ~/.config/tezgah; the pre-multi-host setup wrote them to
232
268
  # ~/.claude, which OFF_DIRS still honors, so a full uninstall sweeps those by
233
269
  # name too - a switch left behind is a rule that stays silent with no file saying why.
234
- OFF_SWITCH_NAMES = ("adhd-off", "agents-off", "consult-off", "docs-judge-off", "exec-mode.off",
270
+ OFF_SWITCH_NAMES = ("adhd-off", "agents-off", "consult-off", "design-off", "docs-judge-off",
271
+ "exec-mode.off",
235
272
  "judge-off", "lang-off", "orchestrate-off", "ponytail-auto.off",
236
273
  "pretooluse-off", "reminder-off", "research-off", "slices-off",
237
274
  "spec-off", "task-off", "triage-off", "update-check-off", "verify-off",
@@ -310,6 +347,73 @@ CURSOR_HOOK_EVENTS = {
310
347
  "afterFileEdit": {"timeout": 10},
311
348
  "afterAgentResponse": {"timeout": 5},
312
349
  }
350
+ # Qwen Code 0.21.13: a matcher is a regex over the runtime tool id, and a
351
+ # command hook's `timeout` is in MILLISECONDS (60000 by default) - the seconds
352
+ # value the other manifests carry would time every hook out. Stop and
353
+ # SubagentStop run the integrity rule, which may ask a judge, so they get more.
354
+ # Qwen takes MCP tools as `mcp__<server>__<tool>`.
355
+ QWEN_PRETOOL_MATCHER = ("^(run_shell_command|monitor|write_file|edit|replace|notebook_edit|"
356
+ "agent|grep_search|mcp__.*)$")
357
+ QWEN_HOOK_EVENTS = (("SessionStart", None, 5000), ("UserPromptSubmit", None, 5000),
358
+ ("PreToolUse", QWEN_PRETOOL_MATCHER, 5000),
359
+ ("PostToolUse", None, 5000), ("PostToolUseFailure", None, 5000),
360
+ ("SubagentStart", None, 5000), ("SubagentStop", None, 30000),
361
+ ("Stop", None, 30000))
362
+ # Factory Droid 0.251.0 (docs.factory.com harness/hooks): a matcher is a
363
+ # case-sensitive regex over the tool id, and `timeout` is in seconds. Droid has
364
+ # no SubagentStart and no failure event. MCP tools are `mcp__<server>__<tool>`.
365
+ DROID_PRETOOL_MATCHER = "^(Execute|Create|Edit|ApplyPatch|Task|Grep|mcp__.*)$"
366
+ DROID_HOOK_EVENTS = (("SessionStart", None, 5), ("UserPromptSubmit", None, 5),
367
+ ("PreToolUse", DROID_PRETOOL_MATCHER, 5), ("PostToolUse", None, 5),
368
+ ("SubagentStop", None, 30), ("Stop", None, 30))
369
+ # Command Code (commandcode.ai/docs/hooks): four events, `timeout` in seconds, and
370
+ # a matcher tested against the tool's display name (SHELL, READ, WRITE, EDIT) -
371
+ # its settings hooks see no other tool, so MCP, subagent and web calls are not
372
+ # gated there. A matcher on SessionStart or Stop stops the hook from firing.
373
+ COMMANDCODE_PRETOOL_MATCHER = "shell|write|edit"
374
+ COMMANDCODE_HOOK_EVENTS = (("SessionStart", None, 5),
375
+ ("PreToolUse", COMMANDCODE_PRETOOL_MATCHER, 5),
376
+ ("PostToolUse", None, 5), ("Stop", None, 30))
377
+ # GitHub Copilot CLI (docs.github.com hooks-reference): tezgah's own file in the
378
+ # user hooks dir, flat entries with `timeoutSec` in seconds. The PascalCase names
379
+ # get snake_case payloads and Claude tool names; `subagentStart` has no PascalCase
380
+ # form, so its camelCase payload names no event and the command passes it. A
381
+ # PascalCase PreToolUse matcher is anchored and tested against the Claude tool
382
+ # name; an MCP tool is `<server>-<tool>`. A timed-out PreToolUse lets the call
383
+ # through, hence 10 s there.
384
+ COPILOT_PRETOOL_MATCHER = "^(?:Bash|Write|Edit|Agent|Task|Grep|.+-.+)$"
385
+ COPILOT_HOOK_EVENTS = (("SessionStart", None, 5), ("UserPromptSubmit", None, 5),
386
+ ("PreToolUse", COPILOT_PRETOOL_MATCHER, 10), ("PostToolUse", None, 5),
387
+ ("PostToolUseFailure", None, 5), ("subagentStart", None, 5),
388
+ ("SubagentStop", None, 30), ("Stop", None, 30))
389
+ # Gemini CLI (geminicli hooks reference): `timeout` in MILLISECONDS, a BeforeTool
390
+ # matcher is a regex over the tool name, a lifecycle matcher an exact string (so
391
+ # none). A subagent is a tool of its own name; an MCP tool is
392
+ # `mcp_<server>_<tool>`. AfterAgent runs the integrity rule, which may ask a judge.
393
+ GEMINI_PRETOOL_MATCHER = ("^(run_shell_command|write_file|replace|grep_search|"
394
+ "search_file_content|codebase_investigator|generalist|"
395
+ "invoke_agent|mcp_.*)$")
396
+ GEMINI_HOOK_EVENTS = (("SessionStart", None, 5000), ("BeforeAgent", None, 5000),
397
+ ("BeforeTool", GEMINI_PRETOOL_MATCHER, 5000),
398
+ ("AfterTool", None, 5000), ("AfterAgent", None, 30000))
399
+ # Antigravity CLI (docs/hooks): `timeout` in seconds; a tool event is a matcher
400
+ # group (a regex over the tool name, "" for every tool), a lifecycle event a bare
401
+ # handler. The payload names no event, so the command does (argv[1]). An MCP
402
+ # tool's name is undocumented, so none is matched. Stop runs the integrity rule.
403
+ ANTIGRAVITY_PRETOOL_MATCHER = ("^(run_command|write_to_file|replace_file_content|"
404
+ "multi_replace_file_content|invoke_subagent|grep_search)$")
405
+ ANTIGRAVITY_HOOK_EVENTS = (("SessionStart", None, 5), ("PreInvocation", None, 5),
406
+ ("PreToolUse", ANTIGRAVITY_PRETOOL_MATCHER, 5),
407
+ ("PostToolUse", "", 5), ("Stop", None, 30))
408
+ # Goose (docs guides/context-engineering/hooks): `timeout` in seconds, a matcher a
409
+ # regex tested unanchored (so anchored here) against the tool name. The built-in
410
+ # developer tools are bare (`shell`, `write`, `edit`), an extension's tool is
411
+ # `<extension>__<tool>`. Only PreToolUse and Stop can block; Stop runs the
412
+ # integrity rule. `on_failure` is left at its default `allow` (X-D2).
413
+ GOOSE_PRETOOL_MATCHER = "^(shell|write|edit|.+__.+)$"
414
+ GOOSE_HOOK_EVENTS = (("SessionStart", None, 5), ("UserPromptSubmit", None, 5),
415
+ ("PreToolUse", GOOSE_PRETOOL_MATCHER, 5), ("PostToolUse", None, 5),
416
+ ("PostToolUseFailure", None, 5), ("Stop", None, 30))
313
417
  # The feature selection for THIS run: None means "read config.json['features']",
314
418
  # and an absent key there means the registry's declared default, which is what
315
419
  # makes a config written before this existed behave exactly as it used to.
@@ -454,14 +558,22 @@ def hook_entries(hosts):
454
558
  what re-arming would change."""
455
559
  out = {}
456
560
  for h in hosts:
457
- if h in ("codex", "cursor"):
458
- groups = codex_hook_groups() if h == "codex" else cursor_hook_entries()
561
+ if h in ("codex", "cursor", "qwen", "droid", "commandcode", "copilot", "gemini",
562
+ "antigravity", "goose"):
563
+ groups = {"codex": codex_hook_groups, "cursor": cursor_hook_entries,
564
+ "qwen": qwen_hook_groups, "droid": droid_hook_groups,
565
+ "commandcode": commandcode_hook_groups,
566
+ "copilot": copilot_hook_entries, "gemini": gemini_hook_groups,
567
+ "antigravity": antigravity_hook_groups,
568
+ "goose": goose_hook_groups}[h]()
459
569
  out[h] = ta.own_groups({"hooks": {ev: [g] for ev, g in groups.items()}})
460
570
  elif h == "opencode":
461
571
  src = os.path.join(HERE, "hosts", "opencode", "plugins", "tezgah.js")
462
572
  out[h] = {d: src for d in ta.OPENCODE_DIRS}
463
573
  elif h == "omp":
464
574
  out[h] = {"bridge": omp_bridge()}
575
+ elif h == "amp":
576
+ out[h] = {"plugin": ta.amp_plugin(HERE)}
465
577
  else:
466
578
  out[h] = ta.registration(h, HERE)[0] or {}
467
579
  return out
@@ -665,7 +777,13 @@ def write_json(path, data):
665
777
  An unchanged write is not free: it moves a host file's mtime and lays down a
666
778
  `.tezgah-bak`, so a command whose whole point is idempotence (a repeated
667
779
  --disable) would report a change it never made. Returns True when it wrote."""
668
- text = json.dumps(data, indent=2) + "\n"
780
+ return write_text(path, json.dumps(data, indent=2) + "\n")
781
+
782
+
783
+ def write_text(path, text):
784
+ """write_json's write for text already rendered (a JSON-with-comments file
785
+ edited in place): unchanged bytes skip it, a backup comes first, and the
786
+ file keeps its mode. Returns True when it wrote."""
669
787
  try:
670
788
  with open(path, encoding="utf-8") as fh:
671
789
  if fh.read() == text:
@@ -675,8 +793,15 @@ def write_json(path, data):
675
793
  mkdirp(os.path.dirname(path))
676
794
  backup(path)
677
795
  tmp = path + ".tmp"
678
- with open(tmp, "w", encoding="utf-8") as fh:
796
+ existed = os.path.exists(path)
797
+ # over an existing file the copy is born owner-only, then takes the file's
798
+ # mode: a settings file holding provider keys is never readable by others,
799
+ # not even between the write and the mode copy
800
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600 if existed else 0o666)
801
+ with open(fd, "w", encoding="utf-8") as fh:
679
802
  fh.write(text)
803
+ if existed:
804
+ shutil.copymode(path, tmp) # an owner-only config stays owner-only
680
805
  os.replace(tmp, path)
681
806
  return True
682
807
 
@@ -713,7 +838,8 @@ def backup_base(path):
713
838
 
714
839
  def backup(path):
715
840
  """Copy `path` to `<backup_base>.<timestamp>.tezgah-bak` before a write
716
- changes it.
841
+ changes it, owner-only: a host config can hold provider keys (Qwen's
842
+ settings.json does), and a copy must not be readable where the file was not.
717
843
 
718
844
  The suffix stays `.tezgah-bak`, so managed() and the uninstall cleanup still
719
845
  know it; past BACKUP_CAP the middle copies go, never the oldest."""
@@ -729,6 +855,7 @@ def backup(path):
729
855
  try:
730
856
  mkdirp(os.path.dirname(dst))
731
857
  shutil.copy2(path, dst)
858
+ os.chmod(dst, 0o600)
732
859
  d, name = os.path.split(base)
733
860
  old = sorted(entry for entry in os.listdir(d or ".")
734
861
  if (m := BACKUP_RE.fullmatch(entry)) and m.group(1) == name)
@@ -1039,7 +1166,10 @@ def install_common(new_roots, hosts):
1039
1166
  (tp.roots() or [tp.DEFAULT_ROOT])
1040
1167
  if tp.codegraph_bin():
1041
1168
  cfg["codegraph_bin"] = tp.codegraph_bin()
1042
- cfg["hosts"] = hosts
1169
+ # merged with the record, never replaced: arming one host must not drop
1170
+ # the others, whose wiring stays on disk and which --upgrade reads from here
1171
+ recorded = cfg.get("hosts") if isinstance(cfg.get("hosts"), list) else []
1172
+ cfg["hosts"] = recorded + [h for h in hosts if h not in recorded]
1043
1173
  # The resolved selection, written beside roots/hosts: the next run reads one
1044
1174
  # persisted answer instead of the flags some earlier run happened to carry,
1045
1175
  # which is what lets --disable mean anything. A key absent here (and in the
@@ -1081,6 +1211,15 @@ def install_common(new_roots, hosts):
1081
1211
  ("tezgah-render-table", os.path.join(HERE, "skills", "plan-add", "render_table.py")),
1082
1212
  ("tezgah-codex-hook", os.path.join(HERE, "hosts", "codex", "hook.py")),
1083
1213
  ("tezgah-cursor-hook", os.path.join(HERE, "hosts", "cursor", "hook.py")),
1214
+ ("tezgah-qwen-hook", os.path.join(HERE, "hosts", "qwen", "hook.py")),
1215
+ ("tezgah-droid-hook", os.path.join(HERE, "hosts", "droid", "hook.py")),
1216
+ ("tezgah-commandcode-hook",
1217
+ os.path.join(HERE, "hosts", "commandcode", "hook.py")),
1218
+ ("tezgah-copilot-hook", os.path.join(HERE, "hosts", "copilot", "hook.py")),
1219
+ ("tezgah-gemini-hook", os.path.join(HERE, "hosts", "gemini", "hook.py")),
1220
+ ("tezgah-antigravity-hook",
1221
+ os.path.join(HERE, "hosts", "antigravity", "hook.py")),
1222
+ ("tezgah-goose-hook", os.path.join(HERE, "hosts", "goose", "hook.py")),
1084
1223
  ("tezgah-statusline", os.path.join(HERE, "statusline.py")),
1085
1224
  ("tezgah-triage", os.path.join(HERE, "bin", "tezgah-triage")),
1086
1225
  ("tezgah-migrate", os.path.join(HERE, "bin", "tezgah-migrate")),
@@ -1090,6 +1229,8 @@ def install_common(new_roots, hosts):
1090
1229
  ("tezgah-route", os.path.join(HERE, "bin", "tezgah-route")),
1091
1230
  ("tezgah-lessons", os.path.join(HERE, "bin", "tezgah-lessons")),
1092
1231
  ("tezgah-skill", os.path.join(HERE, "bin", "tezgah-skill")),
1232
+ ("tezgah-review-quote", os.path.join(HERE, "bin", "tezgah-review-quote")),
1233
+ ("tezgah-ocr-review", os.path.join(HERE, "bin", "tezgah-ocr-review")),
1093
1234
  # `tezgah` is this installer under the name `tezgah update` is typed
1094
1235
  # with (npm ships the same name). Not here on purpose: tezgah-dsh
1095
1236
  # (wired as ~/.local/bin/dsh, see dsh_launcher) and
@@ -1594,7 +1735,7 @@ def opencode_contract():
1594
1735
  def contract_artifacts(hosts):
1595
1736
  """{path: (fresh render, writer)} for every always-on contract artifact
1596
1737
  these hosts read: the managed block of each static rules file (Claude,
1597
- Codex, omp) and opencode's contract and skill routers."""
1738
+ Codex, omp, Qwen, Droid) and opencode's contract and skill routers."""
1598
1739
  out = {}
1599
1740
  for h in hosts:
1600
1741
  if h in STATIC_RULES:
@@ -1647,6 +1788,11 @@ def skill_description(path):
1647
1788
  else:
1648
1789
  inline = re.search(r"description:\s*(.+)", block.group(1))
1649
1790
  desc = inline.group(1).strip().strip("\"'") if inline else ""
1791
+ return _trigger_line(desc)
1792
+
1793
+
1794
+ def _trigger_line(desc):
1795
+ """The sentence of `desc` carrying its `Use when ...` trigger, cut at 140."""
1650
1796
  desc = re.sub(r"\s+", " ", desc).strip()
1651
1797
  sentences = re.split(r"(?<=[.!?])\s+", desc)
1652
1798
  line = next((s for s in sentences if re.match(r"(?:Also )?[Uu]se\b", s)),
@@ -1654,6 +1800,22 @@ def skill_description(path):
1654
1800
  return line[:137].rstrip() + "..." if len(line) > 140 else line
1655
1801
 
1656
1802
 
1803
+ def skill_when_line(path):
1804
+ """The trigger line of a SKILL.md body's `## When to use` first paragraph.
1805
+
1806
+ The frontmatter description is a short bilingual trigger list now, too short
1807
+ for two near-neighbours to share subject words; the long text it replaced
1808
+ opens the body's `## When to use`, and that is what still tells them apart.
1809
+ Best effort like skill_description: anything unreadable yields ""."""
1810
+ try:
1811
+ with open(path, encoding="utf-8", errors="replace") as fh:
1812
+ text = fh.read()
1813
+ except OSError:
1814
+ return ""
1815
+ para = re.search(r"^## When to use\n\s*\n(.*?)(?:\n\s*\n|\Z)", text, re.M | re.S)
1816
+ return _trigger_line(para.group(1)) if para else ""
1817
+
1818
+
1657
1819
  def skill_category(name, path=""):
1658
1820
  if name in RESEARCH_SKILLS:
1659
1821
  return "research & papers"
@@ -1734,12 +1896,12 @@ under out up down off again more most other some such only own same too very can
1734
1896
  will just don should now read reads reading use used uses using also one two
1735
1897
  three every each any all both new old
1736
1898
  """.split())
1737
- # Two skills a session could pick either way from: their router lines share this
1738
- # many subject words, so the description alone cannot settle the choice. Three is
1739
- # where the shipped set stops pairing near-neighbours - `plan-add` / `plan-status`
1740
- # / `plan-sync` share four terms and `product-analysis` / `feature-audit` three,
1741
- # while at two `harness` / `no-ai-slop` pair over "asks, user" and the section
1742
- # reads as noise.
1899
+ # Two skills a session could pick either way from: the trigger lines of their
1900
+ # `## When to use` share this many subject words, so the description alone
1901
+ # cannot settle the choice. Three is where the shipped set pairs near-neighbours -
1902
+ # `plan-add` / `plan-status` / `plan-sync` share four or five terms,
1903
+ # `product-analysis` / `feature-audit` and `design-contract` / `design-library`
1904
+ # three - while `ponytail` / `i-have-adhd` stay apart.
1743
1905
  COLLISION_SHARED = 3
1744
1906
 
1745
1907
 
@@ -1758,7 +1920,7 @@ def skill_collisions(groups):
1758
1920
  router is what a session picks from when the descriptions tie. It reads the
1759
1921
  shipped names only: a vendored library's internal collisions are its own
1760
1922
  index's business, and a pair tezgah cannot rename is not a tezgah decision."""
1761
- lines = {name: skill_description(path)
1923
+ lines = {name: skill_when_line(path)
1762
1924
  for cat in ROUTER_ORDER for name, path in groups[cat]
1763
1925
  if name in SKILLS}
1764
1926
  names = sorted(lines)
@@ -1778,7 +1940,8 @@ def _collision_section(pairs):
1778
1940
  out = [
1779
1941
  "## Collisions",
1780
1942
  "",
1781
- "Pairs a description alone cannot settle: both router lines carry the"
1943
+ "Pairs a description alone cannot settle: both entries' `## When to use`"
1944
+ " lines carry the"
1782
1945
  " words below, so a session could pick either way from them. Read both"
1783
1946
  " entries before choosing.",
1784
1947
  "",
@@ -1856,20 +2019,38 @@ def install_opencode():
1856
2019
  write_artifact(SKILL_ROUTER_FULL, full, write_if_changed)
1857
2020
  print("%s %s (always-on, %d chars)" % (OK, SKILL_ROUTER, len(always)))
1858
2021
  print("%s %s (on demand, %d chars)" % (OK, SKILL_ROUTER_FULL, len(full)))
1859
- # TUI status line: opencode has no command statusLine, only a TUI plugin
1860
- # declared in tui.json. Local path spec resolves relative to tui.json.
1861
- print(link(os.path.join(HERE, "hosts", "opencode", "tui", "tezgah-tui.tsx"),
1862
- os.path.join(OPENCODE, "tui-plugins", "tezgah-tui.tsx")))
1863
- tpath = os.path.join(OPENCODE, "tui.json")
1864
- tui = read_config(tpath, {"$schema": "https://opencode.ai/tui.json"})
1865
- if tui is not None:
1866
- plugins = tui.get("plugin") or []
1867
- spec = "./tui-plugins/tezgah-tui.tsx"
1868
- if spec not in [p if isinstance(p, str) else p[0] for p in plugins]:
1869
- plugins.append(spec)
1870
- tui["plugin"] = plugins
1871
- write_json(tpath, tui)
1872
- print("%s tui.json (status line plugin)" % OK)
2022
+ # TUI status line: opencode has no command statusLine, only a TUI plugin.
2023
+ # opencode 1 declares it in tui.json (a local spec resolves relative to it);
2024
+ # opencode 2 ignores tui.json and lists CLI plugins in cli.json `plugins`. A
2025
+ # local v2 entry must name a DIRECTORY whose `tui` module default-exports
2026
+ # Plugin.define({id, setup}) - a spec naming a file is skipped (measured on
2027
+ # v2.0.32) - so the directory hosts/opencode/tui/cli is what gets linked.
2028
+ v2 = opencode_v2()
2029
+ if v2:
2030
+ dst = OPENCODE_CLI_PLUGIN
2031
+ print(link(os.path.join(HERE, "hosts", "opencode", "tui", "cli"), dst))
2032
+ cpath = os.path.join(OPENCODE, "cli.json")
2033
+ cli = read_config(cpath, {})
2034
+ if cli is not None:
2035
+ plugins = cli.get("plugins") or []
2036
+ if "file://" + dst not in plugins:
2037
+ plugins.append("file://" + dst)
2038
+ cli["plugins"] = plugins
2039
+ write_json(cpath, cli)
2040
+ print("%s cli.json (status line plugin)" % OK)
2041
+ else:
2042
+ print(link(os.path.join(HERE, "hosts", "opencode", "tui", "tezgah-tui.tsx"),
2043
+ os.path.join(OPENCODE, "tui-plugins", "tezgah-tui.tsx")))
2044
+ tpath = os.path.join(OPENCODE, "tui.json")
2045
+ tui = read_config(tpath, {"$schema": "https://opencode.ai/tui.json"})
2046
+ if tui is not None:
2047
+ plugins = tui.get("plugin") or []
2048
+ spec = "./tui-plugins/tezgah-tui.tsx"
2049
+ if spec not in [p if isinstance(p, str) else p[0] for p in plugins]:
2050
+ plugins.append(spec)
2051
+ tui["plugin"] = plugins
2052
+ write_json(tpath, tui)
2053
+ print("%s tui.json (status line plugin)" % OK)
1873
2054
  cfg = read_config(os.path.join(OPENCODE, "opencode.json"),
1874
2055
  {"$schema": "https://opencode.ai/config.json"})
1875
2056
  if cfg is None:
@@ -1921,7 +2102,8 @@ def install_opencode():
1921
2102
  # Context hygiene: prune replaces old tool results in the prompt with
1922
2103
  # "[Old tool result content cleared]" so a long session stops re-sending
1923
2104
  # every output on every step. setdefault keeps a user's explicit choice.
1924
- cfg.setdefault("compaction", {}).setdefault("prune", True)
2105
+ if not v2: # opencode 2 omits it as an unsupported legacy setting
2106
+ cfg.setdefault("compaction", {}).setdefault("prune", True)
1925
2107
  wat = cfg.setdefault("watcher", {})
1926
2108
  ign = wat.setdefault("ignore", [])
1927
2109
  for pat in OPENCODE_IGNORE:
@@ -1929,7 +2111,8 @@ def install_opencode():
1929
2111
  ign.append(pat)
1930
2112
  changed = write_json(os.path.join(OPENCODE, "opencode.json"), cfg)
1931
2113
  print("%s opencode.json (instructions + skill router + mcp + skill deny)" % OK)
1932
- print("%s opencode.json (compaction.prune + watcher.ignore)" % OK)
2114
+ print("%s opencode.json (%swatcher.ignore)"
2115
+ % (OK, "" if v2 else "compaction.prune + "))
1933
2116
  if changed or dropped:
1934
2117
  restart_hint("opencode", os.path.join(OPENCODE, "opencode.json"))
1935
2118
 
@@ -2026,6 +2209,470 @@ def install_cursor():
2026
2209
  restart_hint("cursor", mpath)
2027
2210
 
2028
2211
 
2212
+ def qwen_hook_groups():
2213
+ """{event: the group install_qwen writes}; `hook_entries` plans from it."""
2214
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-qwen-hook"))
2215
+ out = {}
2216
+ for ev, matcher, timeout in QWEN_HOOK_EVENTS:
2217
+ group = {"matcher": matcher} if matcher else {}
2218
+ group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
2219
+ out[ev] = group
2220
+ return out
2221
+
2222
+
2223
+ def install_qwen():
2224
+ """Qwen Code: hook groups and the status line in `settings.json`, skill
2225
+ links, and the always-on block in `QWEN.md`. settings.json is shared - Orca
2226
+ writes its own hooks there and the user keeps provider keys in it - so only a
2227
+ group naming tezgah is replaced, the test uninstall drops by, and the backup
2228
+ `write_json` lays down is owner-only (`backup`)."""
2229
+ print("\nqwen:")
2230
+ path = os.path.join(QWEN, "settings.json")
2231
+ data = read_config(path, {})
2232
+ if data is not None:
2233
+ hooks = data.setdefault("hooks", {})
2234
+ for ev, group in qwen_hook_groups().items():
2235
+ arr = hooks.setdefault(ev, [])
2236
+ arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
2237
+ arr.append(group)
2238
+ # Qwen's command status line: the session JSON on stdin, ANSI kept
2239
+ data.setdefault("ui", {})["statusLine"] = {
2240
+ "type": "command",
2241
+ "command": tp.hook_command(
2242
+ os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --qwen",
2243
+ "respectUserColors": True,
2244
+ }
2245
+ write_json(path, data)
2246
+ print("%s %s (hooks + ui.statusLine)" % (OK, path))
2247
+ for s in SKILLS:
2248
+ print(link(os.path.join(HERE, "skills", s), os.path.join(QWEN, "skills", s)))
2249
+ print("%s %s (always-on contract)" % (OK, write_rules("qwen")))
2250
+
2251
+
2252
+ def droid_hook_groups():
2253
+ """{event: the group install_droid writes}; `hook_entries` plans from it."""
2254
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-droid-hook"))
2255
+ out = {}
2256
+ for ev, matcher, timeout in DROID_HOOK_EVENTS:
2257
+ group = {"matcher": matcher} if matcher else {}
2258
+ group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
2259
+ out[ev] = group
2260
+ return out
2261
+
2262
+
2263
+ # a JSON string, kept, or a `//` / `/* */` comment, dropped
2264
+ JSONC_TOKENS = re.compile(r'("(?:\\.|[^"\\])*")|//[^\n]*|/\*.*?\*/', re.S)
2265
+
2266
+
2267
+ def read_jsonc(path):
2268
+ """The object in a JSON-with-comments file (Droid's settings.json), {} when
2269
+ the file is absent, None when it does not parse. A whole rewrite would drop
2270
+ the user's comments, so the only writers are `jsonc_put`/`jsonc_drop`.
2271
+ ponytail: a trailing comma inside a string value is dropped too; the readers
2272
+ only ask for top-level keys."""
2273
+ try:
2274
+ with open(path, encoding="utf-8") as fh:
2275
+ text = fh.read()
2276
+ except FileNotFoundError:
2277
+ return {}
2278
+ except OSError:
2279
+ return None
2280
+ return jsonc_loads(text)
2281
+
2282
+
2283
+ def droid_hooks_blocked():
2284
+ """Why tezgah must not create ~/.factory/hooks.json, or None. Droid reads the
2285
+ `hooks` key of settings.json only while hooks.json is absent, so creating one
2286
+ beside such a key would switch the user's own hooks off."""
2287
+ if os.path.exists(os.path.join(DROID, "hooks.json")):
2288
+ return None
2289
+ path = os.path.join(DROID, "settings.json")
2290
+ data = read_jsonc(path)
2291
+ if data is None:
2292
+ return "%s does not parse, so its `hooks` key cannot be ruled out" % path
2293
+ if "hooks" in data:
2294
+ return ("%s carries a `hooks` key, which Droid ignores once hooks.json "
2295
+ "exists - move those hooks into hooks.json, then re-run --install" % path)
2296
+ return None
2297
+
2298
+
2299
+ def droid_statusline():
2300
+ return tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --droid"
2301
+
2302
+
2303
+ def jsonc_loads(text):
2304
+ """read_jsonc's parse of text already read: the object, or None."""
2305
+ text = re.sub(r",(\s*[}\]])", r"\1", JSONC_TOKENS.sub(lambda m: m.group(1) or "", text))
2306
+ try:
2307
+ data = json.loads(text)
2308
+ except ValueError:
2309
+ return None
2310
+ return data if isinstance(data, dict) else None
2311
+
2312
+
2313
+ # the object's opening brace, past any comment or string before it
2314
+ JSONC_OPEN = re.compile(r'("(?:\\.|[^"\\])*")|//[^\n]*|/\*.*?\*/|(\{)', re.S)
2315
+
2316
+
2317
+ def jsonc_put(text, key, value):
2318
+ """`text` (JSON with comments) with the top-level `key` set to `value` and
2319
+ every comment kept, or None when that cannot be done safely: the file does
2320
+ not parse, the key holds an object or list, or the edit would change
2321
+ anything else. An absent key goes in as one line under the opening brace,
2322
+ the shape `jsonc_drop` takes back out."""
2323
+ data = jsonc_loads(text)
2324
+ if data is None:
2325
+ return None
2326
+ want = dict(data, **{key: value})
2327
+ if key in data:
2328
+ if data[key] == value:
2329
+ return text
2330
+ if isinstance(data[key], (dict, list)):
2331
+ return None
2332
+ new = re.sub(r'("%s"\s*:\s*)%s' % (re.escape(key), re.escape(json.dumps(data[key]))),
2333
+ lambda m: m.group(1) + json.dumps(value), text, count=1)
2334
+ else:
2335
+ brace = next((m for m in JSONC_OPEN.finditer(text) if m.group(2)), None)
2336
+ if brace is None:
2337
+ return None
2338
+ line = "\n %s: %s%s" % (json.dumps(key), json.dumps(value), "," if data else "")
2339
+ new = text[:brace.end()] + line + text[brace.end():]
2340
+ return new if jsonc_loads(new) == want else None
2341
+
2342
+
2343
+ def jsonc_drop(text, key, value):
2344
+ """`text` without the one-line `key: value` member `jsonc_put` inserted, or
2345
+ None when that exact line is not there or its removal changes anything else."""
2346
+ data = jsonc_loads(text)
2347
+ if data is None or data.get(key) != value:
2348
+ return None
2349
+ new = re.sub(r'\n[ \t]*%s: %s,?' % (re.escape(json.dumps(key)), re.escape(json.dumps(value))),
2350
+ "", text, count=1)
2351
+ # the line's own comma may now sit before the closing brace (comments may
2352
+ # follow it): take it out, unless the file already carried one there
2353
+ tail = r",(\s*}(?:\s|//[^\n]*|/\*.*?\*/)*)$"
2354
+ if not re.search(tail, text, re.S):
2355
+ new = re.sub(tail, r"\1", new, flags=re.S)
2356
+ rest = dict(data)
2357
+ rest.pop(key)
2358
+ return new if jsonc_loads(new) == rest else None
2359
+
2360
+
2361
+ def install_droid():
2362
+ """Factory Droid: hook groups in `~/.factory/hooks.json`, the status line in
2363
+ `settings.json`, skill links, and the always-on block in `AGENTS.md`.
2364
+ hooks.json may hold the user's own groups, so only a group naming tezgah is
2365
+ replaced. It is not created while settings.json declares hooks
2366
+ (`droid_hooks_blocked`). settings.json is JSON with comments, so the status
2367
+ line and `includeCoAuthoredByDroid: false` (no co-author trailer: the
2368
+ no-attribution rule) are edited in place with every comment kept
2369
+ (`jsonc_put`); a file that cannot be edited safely is left alone and named."""
2370
+ print("\ndroid:")
2371
+ path = os.path.join(DROID, "hooks.json")
2372
+ why = droid_hooks_blocked()
2373
+ if why:
2374
+ print("%s %s not written: %s" % (NO, path, why))
2375
+ else:
2376
+ data = read_config(path, {})
2377
+ if data is not None:
2378
+ for ev, group in droid_hook_groups().items():
2379
+ arr = data.setdefault(ev, [])
2380
+ arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
2381
+ arr.append(group)
2382
+ write_json(path, data)
2383
+ print("%s %s (hooks)" % (OK, path))
2384
+ spath = os.path.join(DROID, "settings.json")
2385
+ try:
2386
+ with open(spath, encoding="utf-8") as fh:
2387
+ text = fh.read()
2388
+ except FileNotFoundError:
2389
+ text = "{}\n"
2390
+ except OSError:
2391
+ text = None
2392
+ # the command's stdout is drawn above the input (settings reference)
2393
+ line = {"command": droid_statusline()}
2394
+ try:
2395
+ plain = json.loads(text) if text is not None else None
2396
+ except ValueError:
2397
+ plain = None # comments: edit in place
2398
+ if why:
2399
+ pass # the install stopped at the user's own `hooks` key: settings stays as it is
2400
+ elif isinstance(plain, dict):
2401
+ # plain JSON (an earlier install wrote it): set both keys and rewrite, as before
2402
+ ours = "tezgah-statusline" in json.dumps(plain.get("statusLine") or "")
2403
+ if ours or "statusLine" not in plain:
2404
+ plain["statusLine"] = line
2405
+ plain["includeCoAuthoredByDroid"] = False
2406
+ write_json(spath, plain)
2407
+ print("%s %s (statusLine%s, includeCoAuthoredByDroid false)" % (
2408
+ OK, spath, "" if plain["statusLine"] == line else " left as the user set it"))
2409
+ elif text is not None:
2410
+ old = (jsonc_loads(text) or {}).get("statusLine")
2411
+ stale = old != line and "tezgah-statusline" in json.dumps(old or "")
2412
+ if stale:
2413
+ dropped = jsonc_drop(text, "statusLine", old) # an earlier install's line
2414
+ text, stale = (dropped, False) if dropped is not None else (text, True)
2415
+ # each key on its own: a user's own statusLine never blocks the trailer switch
2416
+ new = jsonc_put(text, "statusLine", line) or text
2417
+ new = jsonc_put(new, "includeCoAuthoredByDroid", False) or new
2418
+ if new != text:
2419
+ write_text(spath, new)
2420
+ done = jsonc_loads(new) or {}
2421
+ print("%s %s (statusLine%s, includeCoAuthoredByDroid %s)" % (
2422
+ OK if done.get("includeCoAuthoredByDroid") is False and not stale else NO, spath,
2423
+ "" if done.get("statusLine") == line else
2424
+ " from an earlier install could not be refreshed: set it with /statusline to `%s`"
2425
+ % line["command"] if stale else " left as the user set it",
2426
+ "false" if done.get("includeCoAuthoredByDroid") is False else "not set: edit by hand"))
2427
+ else:
2428
+ print("%s %s cannot be read: set the status line with /statusline to `%s` and "
2429
+ "`includeCoAuthoredByDroid` to false" % (NO, spath, droid_statusline()))
2430
+ for s in SKILLS:
2431
+ print(link(os.path.join(HERE, "skills", s), os.path.join(DROID, "skills", s)))
2432
+ print("%s %s (always-on contract)" % (OK, write_rules("droid")))
2433
+
2434
+
2435
+ def commandcode_hook_groups():
2436
+ """{event: the group install_commandcode writes}; `hook_entries` plans from it."""
2437
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-commandcode-hook"))
2438
+ out = {}
2439
+ for ev, matcher, timeout in COMMANDCODE_HOOK_EVENTS:
2440
+ group = {"matcher": matcher} if matcher else {}
2441
+ group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
2442
+ out[ev] = group
2443
+ return out
2444
+
2445
+
2446
+ def install_commandcode():
2447
+ """Command Code: hook groups in the `hooks` key of
2448
+ `~/.commandcode/settings.json`, skill links, and the always-on block in
2449
+ `AGENTS.md`. settings.json is the user's file, so only a group naming tezgah
2450
+ is replaced and the backup is owner-only (`backup`). The same write turns
2451
+ off the default `Co-authored-by: CommandCodeBot` commit trailer
2452
+ (`attribution.commit` "", docs/settings) unless the user set a value."""
2453
+ print("\ncommandcode:")
2454
+ path = os.path.join(COMMANDCODE, "settings.json")
2455
+ data = read_config(path, {})
2456
+ if data is not None:
2457
+ hooks = data.setdefault("hooks", {})
2458
+ for ev, group in commandcode_hook_groups().items():
2459
+ arr = hooks.setdefault(ev, [])
2460
+ arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
2461
+ arr.append(group)
2462
+ attr = data.setdefault("attribution", {})
2463
+ if isinstance(attr, dict) and "commit" not in attr:
2464
+ attr["commit"] = ""
2465
+ write_json(path, data)
2466
+ print("%s %s (hooks + attribution.commit)" % (OK, path))
2467
+ if not isinstance(attr, dict) or attr.get("commit") != "":
2468
+ print("%s %s keeps your attribution.commit, so commits still carry a "
2469
+ "co-author trailer" % (NO, path))
2470
+ for s in SKILLS:
2471
+ print(link(os.path.join(HERE, "skills", s), os.path.join(COMMANDCODE, "skills", s)))
2472
+ print("%s %s (always-on contract)" % (OK, write_rules("commandcode")))
2473
+
2474
+
2475
+ def copilot_hook_entries():
2476
+ """{event: the entry install_copilot writes}; `hook_entries` plans from it."""
2477
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-copilot-hook"))
2478
+ out = {}
2479
+ for ev, matcher, timeout in COPILOT_HOOK_EVENTS:
2480
+ entry = {"type": "command", "timeoutSec": timeout,
2481
+ # a camelCase payload names no event, so the command does
2482
+ "command": cmd + " " + ev if ev[0].islower() else cmd}
2483
+ if matcher:
2484
+ entry["matcher"] = matcher
2485
+ out[ev] = entry
2486
+ return out
2487
+
2488
+
2489
+ def copilot_statusline():
2490
+ return tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --copilot"
2491
+
2492
+
2493
+ def install_copilot():
2494
+ """GitHub Copilot CLI: `$COPILOT_HOME/hooks/tezgah.json`, a hooks file wholly
2495
+ tezgah's (the inline `hooks` key of settings.json is never used: one bad item
2496
+ there rejects every hook in it), the status line and `includeCoAuthoredBy`
2497
+ in settings.json, skill links, and the always-on block in
2498
+ `copilot-instructions.md`. settings.json is JSON with comments, so it is
2499
+ written only while it parses as plain JSON; `includeCoAuthoredBy` (default
2500
+ true, a `Co-authored-by` trailer on the agent's commits) is set false unless
2501
+ the user set it."""
2502
+ print("\ncopilot:")
2503
+ path = os.path.join(COPILOT, "hooks", "tezgah.json")
2504
+ write_json(path, {"version": 1, "hooks": {ev: [e] for ev, e in
2505
+ copilot_hook_entries().items()}})
2506
+ print("%s %s (hooks)" % (OK, path))
2507
+ spath = os.path.join(COPILOT, "settings.json")
2508
+ settings = read_json(spath, None) if os.path.exists(spath) else {}
2509
+ if isinstance(settings, dict):
2510
+ settings["statusLine"] = {"type": "command", "command": copilot_statusline()}
2511
+ settings.setdefault("includeCoAuthoredBy", False)
2512
+ write_json(spath, settings)
2513
+ print("%s %s (statusLine + includeCoAuthoredBy)" % (OK, spath))
2514
+ if settings["includeCoAuthoredBy"] is not False:
2515
+ print("%s %s keeps your includeCoAuthoredBy, so commits still carry a "
2516
+ "co-author trailer" % (NO, spath))
2517
+ else:
2518
+ print("%s %s holds comments, which a rewrite would drop: set `statusLine` to "
2519
+ "`%s` and `includeCoAuthoredBy` to false there" % (NO, spath,
2520
+ copilot_statusline()))
2521
+ for s in SKILLS:
2522
+ print(link(os.path.join(HERE, "skills", s), os.path.join(COPILOT, "skills", s)))
2523
+ print("%s %s (always-on contract)" % (OK, write_rules("copilot")))
2524
+
2525
+
2526
+ def gemini_hook_groups():
2527
+ """{event: the group install_gemini writes}; `hook_entries` plans from it."""
2528
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-gemini-hook"))
2529
+ out = {}
2530
+ for ev, matcher, timeout in GEMINI_HOOK_EVENTS:
2531
+ group = {"matcher": matcher} if matcher else {}
2532
+ group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
2533
+ out[ev] = group
2534
+ return out
2535
+
2536
+
2537
+ def install_gemini():
2538
+ """Gemini CLI: tezgah's own extension dir, `gemini-extension.json` beside
2539
+ `hooks/hooks.json` and `skills/` links, wholly tezgah's; and the always-on
2540
+ block in `GEMINI.md`. settings.json is never written: it is JSON with
2541
+ comments and holds the user's keys. Gemini's default prompt adds no
2542
+ co-author trailer and no setting names one, so there is nothing to turn off."""
2543
+ print("\ngemini:")
2544
+ write_json(os.path.join(GEMINI_EXT, "gemini-extension.json"),
2545
+ {"name": "tezgah", "version": plugin_version(),
2546
+ "description": "tezgah lifecycle hooks and skills"})
2547
+ path = os.path.join(GEMINI_EXT, "hooks", "hooks.json")
2548
+ write_json(path, {"hooks": {ev: [g] for ev, g in gemini_hook_groups().items()}})
2549
+ print("%s %s (extension hooks)" % (OK, path))
2550
+ for s in SKILLS:
2551
+ print(link(os.path.join(HERE, "skills", s), os.path.join(GEMINI_EXT, "skills", s)))
2552
+ print("%s %s (always-on contract)" % (OK, write_rules("gemini")))
2553
+
2554
+
2555
+ def antigravity_hook_groups():
2556
+ """{event: the item install_antigravity writes under hooks.json's `tezgah`
2557
+ name}: a matcher group for a tool event, a bare handler for the others
2558
+ (docs/hooks "Schema and file format"); `hook_entries` plans from it."""
2559
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-antigravity-hook"))
2560
+ out = {}
2561
+ for ev, matcher, timeout in ANTIGRAVITY_HOOK_EVENTS:
2562
+ handler = {"type": "command", "command": cmd + " " + ev, "timeout": timeout}
2563
+ out[ev] = handler if matcher is None else {"matcher": matcher, "hooks": [handler]}
2564
+ return out
2565
+
2566
+
2567
+ def antigravity_statusline():
2568
+ return tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --antigravity"
2569
+
2570
+
2571
+ def install_antigravity():
2572
+ """Antigravity CLI: tezgah's hook set under the top-level `tezgah` name of
2573
+ `~/.gemini/config/hooks.json`, beside the user's other names (hooks in
2574
+ antigravity-cli/settings.json are never loaded, docs/hooks); the Antigravity
2575
+ App and IDE read that file too. The status line in
2576
+ `~/.gemini/antigravity-cli/settings.json`, written only while it is plain
2577
+ JSON; skill links in `antigravity-cli/skills`; the always-on block in the
2578
+ `~/.gemini/GEMINI.md` Gemini CLI shares. Antigravity adds no co-author
2579
+ trailer of its own and no setting names one, so there is nothing to turn off."""
2580
+ print("\nantigravity:")
2581
+ data = read_config(AGY_HOOKS, {})
2582
+ if data is not None:
2583
+ data["tezgah"] = {ev: [g] for ev, g in antigravity_hook_groups().items()}
2584
+ write_json(AGY_HOOKS, data)
2585
+ print("%s %s (hooks; the Antigravity App and IDE load them too)" % (OK, AGY_HOOKS))
2586
+ spath = os.path.join(ANTIGRAVITY, "settings.json")
2587
+ settings = read_json(spath, None) if os.path.exists(spath) else {}
2588
+ if isinstance(settings, dict):
2589
+ settings["statusLine"] = {"type": "command", "command": antigravity_statusline()}
2590
+ write_json(spath, settings)
2591
+ print("%s %s (statusLine)" % (OK, spath))
2592
+ else:
2593
+ print("%s %s is not plain JSON, which a rewrite would lose: set `statusLine` "
2594
+ "to {\"type\": \"command\", \"command\": \"%s\"} there"
2595
+ % (NO, spath, antigravity_statusline()))
2596
+ for s in SKILLS:
2597
+ print(link(os.path.join(HERE, "skills", s), os.path.join(ANTIGRAVITY, "skills", s)))
2598
+ print("%s %s (always-on contract)" % (OK, write_rules("antigravity")))
2599
+
2600
+
2601
+ def goose_hook_groups():
2602
+ """{event: the group install_goose writes}; `hook_entries` plans from it."""
2603
+ cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-goose-hook"))
2604
+ out = {}
2605
+ for ev, matcher, timeout in GOOSE_HOOK_EVENTS:
2606
+ group = {"matcher": matcher} if matcher else {}
2607
+ group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
2608
+ out[ev] = group
2609
+ return out
2610
+
2611
+
2612
+ def install_goose():
2613
+ """Goose: tezgah's own plugin dir, `~/.agents/plugins/tezgah/`, wholly
2614
+ tezgah's - `plugin.json` beside `hooks/hooks.json` and `skills/` links (Goose
2615
+ loads them as `tezgah:<skill>`) - and the always-on block in
2616
+ `~/.config/goose/.goosehints`. The user's other `~/.agents` content is never
2617
+ touched. `on_failure` stays at its default `allow`, so a hook fault lets the
2618
+ call through. Neither Goose's docs nor its system prompt add a co-author
2619
+ trailer or name a setting for one, so there is nothing to turn off."""
2620
+ print("\ngoose:")
2621
+ write_json(os.path.join(GOOSE_PLUGIN, "plugin.json"),
2622
+ {"name": "tezgah", "version": plugin_version(),
2623
+ "description": "tezgah lifecycle hooks and skills"})
2624
+ path = os.path.join(GOOSE_PLUGIN, "hooks", "hooks.json")
2625
+ write_json(path, {"hooks": {ev: [g] for ev, g in goose_hook_groups().items()}})
2626
+ print("%s %s (plugin hooks)" % (OK, path))
2627
+ for s in SKILLS:
2628
+ print(link(os.path.join(HERE, "skills", s), os.path.join(GOOSE_PLUGIN, "skills", s)))
2629
+ print("%s %s (always-on contract)" % (OK, write_rules("goose")))
2630
+
2631
+
2632
+ def amp_settings():
2633
+ """(path, settings dict or None): Amp's user settings.json as tezgah may
2634
+ rewrite it - absent reads as {}, and None stands for a file tezgah must not
2635
+ touch: one that is not plain JSON, or a settings.jsonc beside it (which of
2636
+ the two wins is undocumented)."""
2637
+ path = os.path.join(AMP, "settings.json")
2638
+ if os.path.exists(os.path.join(AMP, "settings.jsonc")):
2639
+ return path, None
2640
+ return path, read_json(path, None) if os.path.exists(path) else {}
2641
+
2642
+
2643
+ def install_amp():
2644
+ """Amp: one system plugin file, `$XDG_CONFIG_HOME/amp/plugins/tezgah.ts`,
2645
+ rendered from `hosts/amp/tezgah-plugin.ts.in` (Amp loads every .ts there,
2646
+ nothing registers it); skill links in `~/.config/amp/skills`; the always-on
2647
+ block in `~/.config/amp/AGENTS.md`; and Amp's two commit trailers set off
2648
+ in settings.json unless the user set them."""
2649
+ print("\namp:")
2650
+ plugin = ta.amp_plugin(HERE)
2651
+ if not plugin:
2652
+ print("%s %s: this tree has no hosts/amp/tezgah-plugin.ts.in to render"
2653
+ % (NO, AMP_PLUGIN))
2654
+ elif write_if_changed(AMP_PLUGIN, plugin):
2655
+ print("%s %s (plugin: gate + ledger + Stop rule)" % (OK, AMP_PLUGIN))
2656
+ print("%s amp: run `plugins: reload` in a running Amp to load it" % OK)
2657
+ spath, settings = amp_settings()
2658
+ if isinstance(settings, dict):
2659
+ for key in AMP_ATTRIBUTION:
2660
+ settings.setdefault(key, False)
2661
+ write_json(spath, settings)
2662
+ print("%s %s (commit trailers off)" % (OK, spath))
2663
+ for key in AMP_ATTRIBUTION:
2664
+ if settings[key] is not False:
2665
+ print("%s %s keeps your %s, so the agent's commits still carry "
2666
+ "that trailer" % (NO, spath, key))
2667
+ else:
2668
+ print("%s %s is not plain JSON (or settings.jsonc exists): set %s to false "
2669
+ "there" % (NO, spath, " and ".join("`%s`" % k for k in AMP_ATTRIBUTION)))
2670
+ for s in SKILLS:
2671
+ print(link(os.path.join(HERE, "skills", s), os.path.join(AMP, "skills", s)))
2672
+ print("%s %s (always-on contract)" % (OK, write_rules("amp")))
2673
+
2674
+
2675
+
2029
2676
  def dsh_app_mcp_rows():
2030
2677
  """One dsh-mcp-client row per MCP server tezgah writes into the block.
2031
2678
 
@@ -2410,7 +3057,32 @@ CLAUDE_RULES = os.path.join(CLAUDE, "CLAUDE.md")
2410
3057
  # and where that file is. A lambda per row, because codex_rules() answers from
2411
3058
  # the disk (AGENTS.override.md wins when it exists).
2412
3059
  STATIC_RULES = {"claude": lambda: CLAUDE_RULES, "codex": codex_rules,
2413
- "omp": lambda: os.path.join(OMP_AGENT, "RULES.md")}
3060
+ "omp": lambda: os.path.join(OMP_AGENT, "RULES.md"),
3061
+ "qwen": lambda: os.path.join(QWEN, "QWEN.md"),
3062
+ "droid": lambda: os.path.join(DROID, "AGENTS.md"),
3063
+ "commandcode": lambda: os.path.join(COMMANDCODE, "AGENTS.md"),
3064
+ "copilot": lambda: os.path.join(COPILOT, "copilot-instructions.md"),
3065
+ "gemini": lambda: os.path.join(GEMINI, "GEMINI.md"),
3066
+ "antigravity": lambda: os.path.join(os.path.dirname(ANTIGRAVITY), "GEMINI.md"),
3067
+ "goose": lambda: os.path.join(GOOSE, ".goosehints"),
3068
+ "amp": lambda: os.path.join(AMP, "AGENTS.md")}
3069
+ # Gemini CLI and Antigravity both load ~/.gemini/GEMINI.md, so they share ONE
3070
+ # managed block: an uninstall strips it only while no other host of this tuple
3071
+ # that reads the same file stays armed (`gemini_md_kept`; GEMINI_CLI_HOME moves
3072
+ # Gemini's copy away), and each host's uninstall asks the same question, so the
3073
+ # last one out takes the block.
3074
+ GEMINI_MD_HOSTS = ("gemini", "antigravity")
3075
+ # the hosts the running uninstall takes out (`uninstall` sets it)
3076
+ UNINSTALLING = ()
3077
+
3078
+
3079
+ def gemini_md_kept(host):
3080
+ """True while another GEMINI_MD_HOSTS host this uninstall does not touch is
3081
+ armed and reads `host`'s GEMINI.md."""
3082
+ armed = tp.config().get("hosts") or []
3083
+ path = STATIC_RULES[host]()
3084
+ return any(h in armed and h not in UNINSTALLING and STATIC_RULES[h]() == path
3085
+ for h in GEMINI_MD_HOSTS)
2414
3086
 
2415
3087
 
2416
3088
  def rules_block(host):
@@ -2525,7 +3197,11 @@ def install_omp():
2525
3197
 
2526
3198
  INSTALLERS = {"claude": install_claude, "codex": install_codex,
2527
3199
  "opencode": install_opencode, "cursor": install_cursor,
2528
- "dsh": install_dsh, "omp": install_omp}
3200
+ "dsh": install_dsh, "omp": install_omp, "qwen": install_qwen,
3201
+ "droid": install_droid, "commandcode": install_commandcode,
3202
+ "copilot": install_copilot, "gemini": install_gemini,
3203
+ "antigravity": install_antigravity, "goose": install_goose,
3204
+ "amp": install_amp}
2529
3205
 
2530
3206
 
2531
3207
  def install_openresearch(hosts):
@@ -2945,6 +3621,20 @@ def _run_bounded(argv, timeout):
2945
3621
  except OSError:
2946
3622
  pass
2947
3623
  proc.wait()
3624
+ # a member mid-fork as the kill swept the group can come out of the
3625
+ # fork unsignalled (macOS under load: the orphan held the caller's
3626
+ # pipes, plan 115), so signal the group again until it is empty. Each
3627
+ # round catches what forked since the last and a healthy group is gone
3628
+ # within a few; the 2 s cap only bounds one that will not die (a member
3629
+ # stuck in uninterruptible I/O, a zombie nobody reaps) so the Ctrl-C
3630
+ # still returns.
3631
+ deadline = time.monotonic() + 2
3632
+ while time.monotonic() < deadline:
3633
+ try:
3634
+ os.killpg(proc.pid, signal.SIGKILL)
3635
+ except OSError: # ESRCH: nothing left in the group
3636
+ break
3637
+ time.sleep(0.01)
2948
3638
  raise
2949
3639
 
2950
3640
 
@@ -3363,9 +4053,12 @@ def verify_uninstall(hosts, full):
3363
4053
  if "opencode" in hosts:
3364
4054
  claim("opencode plugin unlinked",
3365
4055
  [os.path.join(OPENCODE, d, "tezgah.js") for d in ("plugins", "plugin")]
3366
- + [os.path.join(OPENCODE, "tui-plugins", "tezgah-tui.tsx")])
4056
+ + [os.path.join(OPENCODE, "tui-plugins", n)
4057
+ for n in ("tezgah-tui.tsx", "tezgah-cli")])
3367
4058
  clean("opencode opencode.json unwired",
3368
4059
  [os.path.join(OPENCODE, "opencode.json")])
4060
+ clean("opencode cli.json unwired", [os.path.join(OPENCODE, "cli.json")],
4061
+ needle=OPENCODE_CLI_PLUGIN)
3369
4062
  claim("opencode generated routers gone", [SKILL_ROUTER, SKILL_ROUTER_FULL])
3370
4063
  if "cursor" in hosts:
3371
4064
  clean("cursor hooks.json unwired", [os.path.join(CURSOR, "hooks.json")])
@@ -3388,6 +4081,67 @@ def verify_uninstall(hosts, full):
3388
4081
  claim("omp skills unlinked",
3389
4082
  [os.path.join(OMP_AGENT, "skills", s) for s in SKILLS
3390
4083
  if os.path.lexists(os.path.join(OMP_AGENT, "skills", s))])
4084
+ if "qwen" in hosts:
4085
+ clean("qwen settings.json unwired", [os.path.join(QWEN, "settings.json")])
4086
+ clean("qwen global QWEN.md block gone", [os.path.join(QWEN, "QWEN.md")],
4087
+ needle="<!-- tezgah:start")
4088
+ claim("qwen skills unlinked",
4089
+ [os.path.join(QWEN, "skills", s) for s in SKILLS
4090
+ if os.path.lexists(os.path.join(QWEN, "skills", s))])
4091
+ if "droid" in hosts:
4092
+ clean("droid hooks.json unwired", [os.path.join(DROID, "hooks.json")])
4093
+ clean("droid settings.json statusLine gone", [os.path.join(DROID, "settings.json")],
4094
+ needle="tezgah-statusline")
4095
+ clean("droid global AGENTS.md block gone", [os.path.join(DROID, "AGENTS.md")],
4096
+ needle="<!-- tezgah:start")
4097
+ claim("droid skills unlinked",
4098
+ [os.path.join(DROID, "skills", s) for s in SKILLS
4099
+ if os.path.lexists(os.path.join(DROID, "skills", s))])
4100
+ if "commandcode" in hosts:
4101
+ clean("commandcode settings.json unwired",
4102
+ [os.path.join(COMMANDCODE, "settings.json")])
4103
+ clean("commandcode global AGENTS.md block gone",
4104
+ [os.path.join(COMMANDCODE, "AGENTS.md")], needle="<!-- tezgah:start")
4105
+ claim("commandcode skills unlinked",
4106
+ [os.path.join(COMMANDCODE, "skills", s) for s in SKILLS
4107
+ if os.path.lexists(os.path.join(COMMANDCODE, "skills", s))])
4108
+ if "copilot" in hosts:
4109
+ claim("copilot hooks/tezgah.json gone",
4110
+ [os.path.join(COPILOT, "hooks", "tezgah.json")])
4111
+ clean("copilot settings.json statusLine gone",
4112
+ [os.path.join(COPILOT, "settings.json")], needle="tezgah-statusline")
4113
+ clean("copilot copilot-instructions.md block gone",
4114
+ [os.path.join(COPILOT, "copilot-instructions.md")], needle="<!-- tezgah:start")
4115
+ claim("copilot skills unlinked",
4116
+ [os.path.join(COPILOT, "skills", s) for s in SKILLS
4117
+ if os.path.lexists(os.path.join(COPILOT, "skills", s))])
4118
+ if "gemini" in hosts:
4119
+ claim("gemini extension dir gone", [GEMINI_EXT])
4120
+ if not gemini_md_kept("gemini"):
4121
+ clean("gemini GEMINI.md block gone", [os.path.join(GEMINI, "GEMINI.md")],
4122
+ needle="<!-- tezgah:start")
4123
+ if "antigravity" in hosts:
4124
+ clean("antigravity config/hooks.json unwired", [AGY_HOOKS],
4125
+ needle="tezgah-antigravity-hook")
4126
+ clean("antigravity settings.json statusLine gone",
4127
+ [os.path.join(ANTIGRAVITY, "settings.json")], needle="tezgah-statusline")
4128
+ claim("antigravity skills unlinked",
4129
+ [os.path.join(ANTIGRAVITY, "skills", s) for s in SKILLS
4130
+ if os.path.lexists(os.path.join(ANTIGRAVITY, "skills", s))])
4131
+ if not gemini_md_kept("antigravity"):
4132
+ clean("antigravity GEMINI.md block gone", [STATIC_RULES["antigravity"]()],
4133
+ needle="<!-- tezgah:start")
4134
+ if "goose" in hosts:
4135
+ claim("goose plugin dir gone", [GOOSE_PLUGIN])
4136
+ clean("goose .goosehints block gone", [STATIC_RULES["goose"]()],
4137
+ needle="<!-- tezgah:start")
4138
+ if "amp" in hosts:
4139
+ claim("amp plugin gone", [AMP_PLUGIN] if amp_plugin_ours() else [])
4140
+ claim("amp skills unlinked",
4141
+ [os.path.join(AMP, "skills", s) for s in SKILLS
4142
+ if os.path.lexists(os.path.join(AMP, "skills", s))])
4143
+ clean("amp AGENTS.md block gone", [STATIC_RULES["amp"]()],
4144
+ needle="<!-- tezgah:start")
3391
4145
  if "claude" in hosts:
3392
4146
  clean("claude settings.json unwired",
3393
4147
  [os.path.join(CLAUDE, "settings.json")])
@@ -3499,6 +4253,14 @@ def unwire_opencode_config():
3499
4253
  tj.pop("plugin", None)
3500
4254
  write_json(t, tj)
3501
4255
  print("%s removed tezgah TUI plugin from %s" % (OK, t))
4256
+ cp = os.path.join(OPENCODE, "cli.json")
4257
+ cj = read_json(cp, None)
4258
+ if cj and isinstance(cj.get("plugins"), list):
4259
+ kept = [x for x in cj["plugins"] if x != "file://" + OPENCODE_CLI_PLUGIN]
4260
+ if kept != cj["plugins"]:
4261
+ cj["plugins"] = kept
4262
+ write_json(cp, cj)
4263
+ print("%s removed tezgah CLI plugin from %s" % (OK, cp))
3502
4264
 
3503
4265
 
3504
4266
  def uninstall_claude():
@@ -3560,7 +4322,8 @@ def uninstall_opencode():
3560
4322
  unlink_tezgah(os.path.join(OPENCODE, "skills", s))
3561
4323
  for d in ("plugins", "plugin"):
3562
4324
  unlink_tezgah(os.path.join(OPENCODE, d, "tezgah.js"))
3563
- unlink_tezgah(os.path.join(OPENCODE, "tui-plugins", "tezgah-tui.tsx"))
4325
+ for name in ("tezgah-tui.tsx", "tezgah-cli"):
4326
+ unlink_tezgah(os.path.join(OPENCODE, "tui-plugins", name))
3564
4327
  unwire_opencode_config()
3565
4328
  for path in (SKILL_ROUTER, SKILL_ROUTER_FULL):
3566
4329
  try:
@@ -3594,6 +4357,213 @@ def uninstall_cursor():
3594
4357
  print("%s removed tezgah statusLine from %s" % (OK, cc))
3595
4358
 
3596
4359
 
4360
+ def uninstall_qwen():
4361
+ print("\nqwen:")
4362
+ for s in SKILLS:
4363
+ unlink_tezgah(os.path.join(QWEN, "skills", s))
4364
+ path = os.path.join(QWEN, "settings.json")
4365
+ drop_managed_hook_entries(path)
4366
+ cfg = read_json(path, None)
4367
+ ui = cfg.get("ui") if isinstance(cfg, dict) else None
4368
+ if isinstance(ui, dict) and "tezgah-statusline" in json.dumps(ui.get("statusLine") or {}):
4369
+ ui.pop("statusLine")
4370
+ if not ui:
4371
+ cfg.pop("ui")
4372
+ write_json(path, cfg)
4373
+ print("%s removed tezgah statusLine from %s" % (OK, path))
4374
+ drop_if_empty(path)
4375
+ strip_managed_md(os.path.join(QWEN, "QWEN.md"))
4376
+
4377
+
4378
+ def drop_if_empty(path):
4379
+ """Remove a host settings file uninstall emptied: tezgah created it to hold
4380
+ its hooks or status line, and a `{}` the user never had is left over."""
4381
+ if read_json(path, None) == {}:
4382
+ backup(path)
4383
+ os.remove(path)
4384
+ print("%s removed %s (empty once tezgah's keys left)" % (OK, path))
4385
+
4386
+
4387
+ def uninstall_droid():
4388
+ print("\ndroid:")
4389
+ for s in SKILLS:
4390
+ unlink_tezgah(os.path.join(DROID, "skills", s))
4391
+ path = os.path.join(DROID, "hooks.json")
4392
+ data = read_json(path, None)
4393
+ if isinstance(data, dict):
4394
+ kept = {}
4395
+ for ev, arr in data.items():
4396
+ if isinstance(arr, list):
4397
+ arr = [e for e in arr if "tezgah" not in json.dumps(e)]
4398
+ if not arr:
4399
+ continue
4400
+ kept[ev] = arr
4401
+ if kept == data:
4402
+ print("%s no tezgah hook entries in %s" % (OK, path))
4403
+ elif kept:
4404
+ write_json(path, kept)
4405
+ print("%s removed tezgah hook entries from %s" % (OK, path))
4406
+ else:
4407
+ # an empty hooks.json would still hide the `hooks` key of settings.json
4408
+ backup(path)
4409
+ os.remove(path)
4410
+ print("%s removed %s (it held only tezgah's hooks)" % (OK, path))
4411
+ spath = os.path.join(DROID, "settings.json")
4412
+ cfg = read_json(spath, None)
4413
+ if isinstance(cfg, dict) and "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
4414
+ cfg.pop("statusLine")
4415
+ write_json(spath, cfg)
4416
+ print("%s removed tezgah statusLine from %s" % (OK, spath))
4417
+ elif cfg is None and os.path.exists(spath):
4418
+ # JSON with comments: take out the one line install_droid put in, keep
4419
+ # the comments; `includeCoAuthoredByDroid` stays false (the
4420
+ # no-attribution rule is the user's, not tezgah's to undo)
4421
+ with open(spath, encoding="utf-8") as fh:
4422
+ text = fh.read()
4423
+ old = (jsonc_loads(text) or {}).get("statusLine")
4424
+ new = jsonc_drop(text, "statusLine", old) if "tezgah-statusline" in json.dumps(old or "") else None
4425
+ if new is not None:
4426
+ write_text(spath, new)
4427
+ print("%s removed tezgah statusLine from %s" % (OK, spath))
4428
+ drop_if_empty(spath)
4429
+ strip_managed_md(os.path.join(DROID, "AGENTS.md"))
4430
+
4431
+
4432
+ def uninstall_commandcode():
4433
+ print("\ncommandcode:")
4434
+ for s in SKILLS:
4435
+ unlink_tezgah(os.path.join(COMMANDCODE, "skills", s))
4436
+ path = os.path.join(COMMANDCODE, "settings.json")
4437
+ drop_managed_hook_entries(path)
4438
+ cfg = read_json(path, None)
4439
+ attr = cfg.get("attribution") if isinstance(cfg, dict) else None
4440
+ # the value install wrote, beside the user's other attribution keys or
4441
+ # alone; as with Claude's, a user's own identical value cannot be told
4442
+ # apart from it, and dropping it only restores Command Code's default
4443
+ if isinstance(attr, dict) and attr.get("commit") == "":
4444
+ attr.pop("commit")
4445
+ if not attr:
4446
+ cfg.pop("attribution")
4447
+ write_json(path, cfg)
4448
+ print("%s removed tezgah's attribution.commit from %s" % (OK, path))
4449
+ drop_if_empty(path)
4450
+ strip_managed_md(os.path.join(COMMANDCODE, "AGENTS.md"))
4451
+
4452
+
4453
+ def uninstall_copilot():
4454
+ print("\ncopilot:")
4455
+ for s in SKILLS:
4456
+ unlink_tezgah(os.path.join(COPILOT, "skills", s))
4457
+ path = os.path.join(COPILOT, "hooks", "tezgah.json")
4458
+ if os.path.exists(path):
4459
+ backup(path)
4460
+ os.remove(path)
4461
+ print("%s removed %s" % (OK, path))
4462
+ spath = os.path.join(COPILOT, "settings.json")
4463
+ cfg = read_json(spath, None)
4464
+ if isinstance(cfg, dict):
4465
+ if "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
4466
+ cfg.pop("statusLine")
4467
+ # the value install wrote; a user's own false cannot be told apart from it
4468
+ if cfg.get("includeCoAuthoredBy") is False:
4469
+ cfg.pop("includeCoAuthoredBy")
4470
+ if write_json(spath, cfg):
4471
+ print("%s removed tezgah's statusLine and includeCoAuthoredBy from %s"
4472
+ % (OK, spath))
4473
+ drop_if_empty(spath)
4474
+ strip_managed_md(os.path.join(COPILOT, "copilot-instructions.md"))
4475
+
4476
+
4477
+ def uninstall_gemini():
4478
+ """Delete tezgah's extension dir (its skills are links, unlinked, never
4479
+ followed) and the GEMINI.md block unless Antigravity still reads it."""
4480
+ print("\ngemini:")
4481
+ if os.path.lexists(GEMINI_EXT):
4482
+ for name in ("gemini-extension.json", os.path.join("hooks", "hooks.json")):
4483
+ backup(os.path.join(GEMINI_EXT, name))
4484
+ shutil.rmtree(GEMINI_EXT)
4485
+ print("%s removed %s" % (OK, GEMINI_EXT))
4486
+ if gemini_md_kept("gemini"):
4487
+ print("%s %s keeps the tezgah block: another armed host reads it"
4488
+ % (OK, os.path.join(GEMINI, "GEMINI.md")))
4489
+ else:
4490
+ strip_managed_md(os.path.join(GEMINI, "GEMINI.md"))
4491
+
4492
+
4493
+ def uninstall_antigravity():
4494
+ """Take the `tezgah` name out of ~/.gemini/config/hooks.json (the user's
4495
+ other names stay), the statusLine, the skill links, and the GEMINI.md block
4496
+ unless Gemini CLI still reads it."""
4497
+ print("\nantigravity:")
4498
+ for s in SKILLS:
4499
+ unlink_tezgah(os.path.join(ANTIGRAVITY, "skills", s))
4500
+ data = read_json(AGY_HOOKS, None)
4501
+ if isinstance(data, dict) and "tezgah" in data:
4502
+ data.pop("tezgah")
4503
+ write_json(AGY_HOOKS, data)
4504
+ print("%s removed tezgah's hooks from %s" % (OK, AGY_HOOKS))
4505
+ drop_if_empty(AGY_HOOKS)
4506
+ spath = os.path.join(ANTIGRAVITY, "settings.json")
4507
+ cfg = read_json(spath, None)
4508
+ if isinstance(cfg, dict) and "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
4509
+ cfg.pop("statusLine")
4510
+ write_json(spath, cfg)
4511
+ print("%s removed tezgah's statusLine from %s" % (OK, spath))
4512
+ drop_if_empty(spath)
4513
+ rules = STATIC_RULES["antigravity"]()
4514
+ if gemini_md_kept("antigravity"):
4515
+ print("%s %s keeps the tezgah block: another armed host reads it" % (OK, rules))
4516
+ else:
4517
+ strip_managed_md(rules)
4518
+
4519
+
4520
+ def uninstall_goose():
4521
+ """Delete tezgah's plugin dir (its skills are links, unlinked, never
4522
+ followed) and the .goosehints block; the rest of ~/.agents is the user's."""
4523
+ print("\ngoose:")
4524
+ if os.path.lexists(GOOSE_PLUGIN):
4525
+ for name in ("plugin.json", os.path.join("hooks", "hooks.json")):
4526
+ backup(os.path.join(GOOSE_PLUGIN, name))
4527
+ shutil.rmtree(GOOSE_PLUGIN)
4528
+ print("%s removed %s" % (OK, GOOSE_PLUGIN))
4529
+ strip_managed_md(STATIC_RULES["goose"]())
4530
+
4531
+
4532
+ def amp_plugin_ours():
4533
+ """True when the file at AMP_PLUGIN is tezgah's: a fresh render, or one an
4534
+ older tree rendered (it starts with AMP_HEADER). A user's own plugin of
4535
+ that name is never removed."""
4536
+ try:
4537
+ with open(AMP_PLUGIN, encoding="utf-8") as fh:
4538
+ text = fh.read()
4539
+ except OSError:
4540
+ return False
4541
+ return text == ta.amp_plugin(HERE) or text.startswith(ta.AMP_HEADER)
4542
+
4543
+
4544
+ def uninstall_amp():
4545
+ """Delete tezgah's plugin file (only when it is tezgah's), the skill links,
4546
+ the AGENTS.md block, and each commit-trailer key while it is still false."""
4547
+ print("\namp:")
4548
+ if amp_plugin_ours():
4549
+ backup(AMP_PLUGIN)
4550
+ os.remove(AMP_PLUGIN)
4551
+ print("%s removed %s" % (OK, AMP_PLUGIN))
4552
+ elif os.path.lexists(AMP_PLUGIN):
4553
+ print("%s %s is not tezgah's render: left in place" % (NO, AMP_PLUGIN))
4554
+ for s in SKILLS:
4555
+ unlink_tezgah(os.path.join(AMP, "skills", s))
4556
+ spath, settings = amp_settings()
4557
+ if isinstance(settings, dict) and any(settings.get(k) is False for k in AMP_ATTRIBUTION):
4558
+ for key in AMP_ATTRIBUTION:
4559
+ if settings.get(key) is False:
4560
+ settings.pop(key)
4561
+ write_json(spath, settings)
4562
+ print("%s removed tezgah's commit-trailer keys from %s" % (OK, spath))
4563
+ drop_if_empty(spath)
4564
+ strip_managed_md(STATIC_RULES["amp"]())
4565
+
4566
+
3597
4567
  def uninstall_dsh():
3598
4568
  print("\ndsh:")
3599
4569
  for s in SKILLS:
@@ -3646,7 +4616,11 @@ def uninstall_omp():
3646
4616
 
3647
4617
  UNINSTALLERS = {"claude": uninstall_claude, "codex": uninstall_codex,
3648
4618
  "opencode": uninstall_opencode, "cursor": uninstall_cursor,
3649
- "dsh": uninstall_dsh, "omp": uninstall_omp}
4619
+ "dsh": uninstall_dsh, "omp": uninstall_omp, "qwen": uninstall_qwen,
4620
+ "droid": uninstall_droid, "commandcode": uninstall_commandcode,
4621
+ "copilot": uninstall_copilot, "gemini": uninstall_gemini,
4622
+ "antigravity": uninstall_antigravity, "goose": uninstall_goose,
4623
+ "amp": uninstall_amp}
3650
4624
 
3651
4625
 
3652
4626
  def uninstall(hosts, dry_run=False):
@@ -3668,6 +4642,7 @@ def uninstall(hosts, dry_run=False):
3668
4642
  repository's `.tezgah/` research state. A partial run (some armed hosts
3669
4643
  stay) also keeps config.json and the install tree, because the hosts still
3670
4644
  armed read both at runtime."""
4645
+ global UNINSTALLING
3671
4646
  armed = tp.config().get("hosts") or []
3672
4647
  full = not armed or set(hosts) >= set(armed)
3673
4648
  if dry_run:
@@ -3699,6 +4674,7 @@ def uninstall(hosts, dry_run=False):
3699
4674
  print("%s gate stays armed for the hosts this run does not touch" % OK)
3700
4675
  print("uninstalling for: %s (%s run)\n"
3701
4676
  % (", ".join(hosts) or "none detected", "full" if full else "partial"))
4677
+ UNINSTALLING = tuple(hosts)
3702
4678
  uninstall_common()
3703
4679
  for h in hosts:
3704
4680
  UNINSTALLERS[h]()
@@ -3714,6 +4690,13 @@ def uninstall(hosts, dry_run=False):
3714
4690
  remove_install_tree()
3715
4691
  elif armed:
3716
4692
  rest = sorted(set(armed) - set(hosts))
4693
+ # install_common only ever merges into the record, so this is the one
4694
+ # place a host leaves it; kept, it made every later uninstall partial
4695
+ # and `tezgah update` re-armed it from the record
4696
+ cfg = read_config(tp.CONFIG, {})
4697
+ if cfg is not None:
4698
+ cfg["hosts"] = [h for h in armed if h in rest]
4699
+ write_json(tp.CONFIG, cfg)
3717
4700
  print("\n%s still armed: %s keeps its config and the install tree"
3718
4701
  % (NO, ", ".join(rest)))
3719
4702
  remaining = verify_uninstall(hosts, full)
@@ -4034,12 +5017,13 @@ def context_budget_report():
4034
5017
 
4035
5018
  LIVE_TIMEOUT = 20
4036
5019
  LIVE_DENY_COMMAND = "git commit --no-verify -m x"
4037
- # The JS half: loads the omp extension (`--experimental-strip-types`, the .ts as
4038
- # omp's bun would) or the opencode plugin (an ES module, as opencode imports
4039
- # it), registers its handlers against inert host objects and fires one handler:
4040
- # the tool-result one, or with a command the tool-call one, printing what it
4041
- # answered (omp) or what it threw (opencode) as JSON. argv: host, module path,
4042
- # root, session id, command ("" for the tool result).
5020
+ # The JS half: loads the omp extension or the Amp plugin
5021
+ # (`--experimental-strip-types`, the .ts as their bun would) or the opencode
5022
+ # plugin (an ES module, as opencode imports it), registers its handlers against
5023
+ # inert host objects and fires one handler: the tool-result one, or with a
5024
+ # command the tool-call one, printing what it answered (omp, Amp) or what it
5025
+ # threw (opencode) as JSON. argv: host, module path, root, session id, command
5026
+ # ("" for the tool result).
4043
5027
  LIVE_DRIVER = r"""
4044
5028
  const [host, mod, root, sid, cmd] = process.argv.slice(1);
4045
5029
  const noop = new Proxy(function () {}, { get: () => noop, apply: () => noop });
@@ -4060,6 +5044,25 @@ const noop = new Proxy(function () {}, { get: () => noop, apply: () => noop });
4060
5044
  }
4061
5045
  await handlers.tool_result({ toolName: "bash", input: { command: "true" },
4062
5046
  isError: false, content: "ok" }, ctx);
5047
+ } else if (host === "amp") {
5048
+ // Amp's registry, its two helpers the plugin reads, and a thread context;
5049
+ // no status item (`experimental` absent), so no status spawn either
5050
+ const handlers = {};
5051
+ m.default(new Proxy({ on: (n, fn) => { handlers[n] = fn; }, experimental: undefined,
5052
+ system: { workspaceRoot: root },
5053
+ helpers: { filePathFromURI: (u) => String(u),
5054
+ shellCommandFromToolCall: (e) => ({ command: e.input.cmd }) } },
5055
+ { get: (t, k) => (k in t ? t[k] : noop) }));
5056
+ const want = cmd ? "tool.call" : "tool.result";
5057
+ if (!handlers[want]) throw new Error("the plugin registered no " + want + " handler");
5058
+ const ctx = { thread: { id: sid, parentThreadID: async () => null }, ui: noop };
5059
+ const event = { tool: "Bash", input: { cmd: cmd || "true" }, toolUseID: "live",
5060
+ thread: { id: sid } };
5061
+ if (cmd) {
5062
+ process.stdout.write(JSON.stringify((await handlers[want](event, ctx)) || {}));
5063
+ return;
5064
+ }
5065
+ await handlers[want](Object.assign(event, { status: "done", output: "ok" }), ctx);
4063
5066
  } else {
4064
5067
  const hooks = await (m.Tezgah || m.default)({ directory: root, worktree: root,
4065
5068
  client: noop, $: noop });
@@ -4222,6 +5225,123 @@ def live_plan(host, deny=False):
4222
5225
  if registered is None:
4223
5226
  return plan + ("registration unread: the omp CLI did not answer",)
4224
5227
  return plan
5228
+ if host == "qwen":
5229
+ # the hook command Qwen runs for its shell tool, fed Qwen's own payload;
5230
+ # no qwen binary and no model call (tests/e2e_qwen.py drives the binary)
5231
+ path = os.path.join(QWEN, "settings.json")
5232
+ cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event),
5233
+ "run_shell_command")
5234
+ if not cmd:
5235
+ return ("miss", "no tezgah %s command for run_shell_command in %s"
5236
+ % (event, path))
5237
+ payload = {"hook_event_name": event, "tool_name": "run_shell_command",
5238
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
5239
+ if not deny:
5240
+ payload["tool_response"] = {"llmContent": "", "returnDisplay": ""}
5241
+ return ("shell", cmd, payload, {})
5242
+ if host == "droid":
5243
+ # the hook command Droid runs for Execute, fed Droid's own payload; no
5244
+ # droid binary and no model call (tests/e2e_droid.py drives the binary)
5245
+ path = os.path.join(DROID, "hooks.json")
5246
+ cmd = _hook_command(read_json(path, {}).get(event), "Execute")
5247
+ if not cmd:
5248
+ return ("miss", "no tezgah %s command for Execute in %s" % (event, path))
5249
+ payload = {"hook_event_name": event, "tool_name": "Execute",
5250
+ "permission_mode": "auto-low",
5251
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
5252
+ if not deny:
5253
+ payload["tool_response"] = "\n\n[Process exited with code 0]"
5254
+ return ("shell", cmd, payload, {})
5255
+ if host == "commandcode":
5256
+ # the hook command Command Code runs for its shell tool (the matcher sees
5257
+ # the display name SHELL, case-insensitively), fed its own payload; no
5258
+ # binary and no model call
5259
+ path = os.path.join(COMMANDCODE, "settings.json")
5260
+ cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event), "shell")
5261
+ if not cmd:
5262
+ return ("miss", "no tezgah %s command for shell in %s" % (event, path))
5263
+ payload = {"hook_event_name": event, "tool_name": "shell_command",
5264
+ "tool_display_name": "SHELL", "permission_mode": "default",
5265
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
5266
+ if not deny:
5267
+ payload["tool_response"] = ""
5268
+ return ("shell", cmd, payload, {})
5269
+ if host == "copilot":
5270
+ # the hook command Copilot runs for its shell tool (a PascalCase
5271
+ # PreToolUse sees the Claude name Bash), fed its own payload; no binary
5272
+ # and no model call
5273
+ path = os.path.join(COPILOT, "hooks", "tezgah.json")
5274
+ cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event), "Bash",
5275
+ own=True)
5276
+ if not cmd:
5277
+ return ("miss", "no tezgah %s command for Bash in %s" % (event, path))
5278
+ payload = {"hook_event_name": event, "tool_name": "Bash",
5279
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
5280
+ if not deny:
5281
+ payload["tool_result"] = {"result_type": "success",
5282
+ "text_result_for_llm": "<exited with exit code 0>"}
5283
+ return ("shell", cmd, payload, {})
5284
+ if host == "gemini":
5285
+ # the hook command Gemini runs for its shell tool (BeforeTool/AfterTool),
5286
+ # fed Gemini's own payload; no binary and no model call
5287
+ path = os.path.join(GEMINI_EXT, "hooks", "hooks.json")
5288
+ name = "BeforeTool" if deny else "AfterTool"
5289
+ cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(name),
5290
+ "run_shell_command", own=True)
5291
+ if not cmd:
5292
+ return ("miss", "no tezgah %s command for run_shell_command in %s"
5293
+ % (name, path))
5294
+ payload = {"hook_event_name": name, "tool_name": "run_shell_command",
5295
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
5296
+ if not deny:
5297
+ payload["tool_response"] = {"llmContent": "Output: (empty)",
5298
+ "returnDisplay": ""}
5299
+ return ("shell", cmd, payload, {})
5300
+ if host == "antigravity":
5301
+ # the hook command Antigravity runs for run_command, under the `tezgah`
5302
+ # name of ~/.gemini/config/hooks.json; it carries the event as argv[1],
5303
+ # since the camelCase payload names none. No binary and no model call
5304
+ cmd = _hook_command((read_json(AGY_HOOKS, {}).get("tezgah") or {}).get(event),
5305
+ "run_command", own=True)
5306
+ if not cmd:
5307
+ return ("miss", "no tezgah %s command for run_command in %s" % (event, AGY_HOOKS))
5308
+ payload = {"toolCall": {"name": "run_command", "args": {
5309
+ "CommandLine": LIVE_DENY_COMMAND if deny else "true", "WaitMsBeforeAsync": 5000}},
5310
+ "stepIdx": 0}
5311
+ if not deny:
5312
+ payload["error"] = ""
5313
+ return ("shell", cmd, payload, {})
5314
+ if host == "goose":
5315
+ # the hook command Goose runs for its `shell` tool, from tezgah's plugin
5316
+ # hooks.json, fed Goose's own payload (the event in `event`, the
5317
+ # directory as `working_dir`); no binary and no model call
5318
+ path = os.path.join(GOOSE_PLUGIN, "hooks", "hooks.json")
5319
+ cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event), "shell",
5320
+ own=True)
5321
+ if not cmd:
5322
+ return ("miss", "no tezgah %s command for shell in %s" % (event, path))
5323
+ return ("shell", cmd, {"event": event, "tool_name": "shell",
5324
+ "matcher_context": "shell", "tool_call_id": "tezgah-live",
5325
+ "tool_input": {"command": LIVE_DENY_COMMAND if deny
5326
+ else "true"}}, {})
5327
+ if host == "amp":
5328
+ # the installed plugin itself, loaded by node through LIVE_DRIVER and fed
5329
+ # Amp's own event shape (`tool`, `input.cmd`, `thread.id`); the hook it
5330
+ # spawns is the one Amp would spawn
5331
+ try:
5332
+ with open(AMP_PLUGIN, encoding="utf-8") as fh:
5333
+ text = fh.read()
5334
+ except OSError:
5335
+ return ("miss", "no plugin at %s" % AMP_PLUGIN)
5336
+ if text != ta.amp_plugin(HERE):
5337
+ return ("miss", "%s differs from a fresh render of this tree's plugin "
5338
+ "- re-run `tezgah-setup --install --hosts amp`" % AMP_PLUGIN)
5339
+ node = _node_for_ts()
5340
+ if not node:
5341
+ return ("unverified", "no node 22.6+ to load %s" % AMP_PLUGIN)
5342
+ # always unverified: Amp's own Bun loader is not what ran it
5343
+ return ("node", [node, "--experimental-strip-types", "--no-warnings"], AMP_PLUGIN,
5344
+ "Amp's own plugin loader is not driven here")
4225
5345
  return ("unverified", "no live probe for this host")
4226
5346
 
4227
5347
 
@@ -4239,6 +5359,21 @@ def _denied(host, stdout):
4239
5359
  return got.get("block") is True
4240
5360
  if host == "opencode":
4241
5361
  return bool(got.get("threw"))
5362
+ if host == "copilot":
5363
+ # top level, not wrapped (hooks-reference "preToolUse decision control")
5364
+ return got.get("permissionDecision") == "deny"
5365
+ if host == "gemini":
5366
+ # top level (hooks reference "BeforeTool"; `block` is its alias)
5367
+ return got.get("decision") in ("deny", "block")
5368
+ if host == "antigravity":
5369
+ # top level; `decision` is required, so an allow answers too (docs/hooks)
5370
+ return got.get("decision") == "deny"
5371
+ if host == "goose":
5372
+ # top level, stdout starting with `{` (hooks "Blocking a Tool Call")
5373
+ return got.get("decision") == "block"
5374
+ if host == "amp":
5375
+ # ToolCallResult (plugin-api): the call is refused, the turn goes on
5376
+ return got.get("action") == "reject-and-continue"
4242
5377
  return (got.get("hookSpecificOutput") or {}).get("permissionDecision") == "deny"
4243
5378
 
4244
5379
 
@@ -4271,7 +5406,13 @@ def live_probe(host, deny=False):
4271
5406
  if plan[0] == "shell":
4272
5407
  _kind, cmd, payload, extra = plan
4273
5408
  env.update(extra)
4274
- payload = dict(payload, session_id=sid, conversation_id=sid, cwd=root)
5409
+ # Antigravity's envelope is camelCase, with no session_id or cwd;
5410
+ # Goose names the directory `working_dir`
5411
+ payload = (dict(payload, conversationId=sid, workspacePaths=[root])
5412
+ if host == "antigravity" else
5413
+ dict(payload, session_id=sid, working_dir=root)
5414
+ if host == "goose" else
5415
+ dict(payload, session_id=sid, conversation_id=sid, cwd=root))
4275
5416
  proc = subprocess.run(cmd, shell=True, input=json.dumps(payload),
4276
5417
  capture_output=True, text=True, env=env,
4277
5418
  cwd=root, timeout=LIVE_TIMEOUT)
@@ -4344,7 +5485,9 @@ ARMING_ROWS = ("hooks.json wired", "plugin wired (link or copy)",
4344
5485
  "carries the contract", "contract in instructions",
4345
5486
  "claude-code hooks bridge in cordis.patch.yml",
4346
5487
  "session, prompt, tool and stop hooks wired",
4347
- "hook registered in omp extensions", "plugin copy current")
5488
+ "hook registered in omp extensions", "plugin copy current",
5489
+ "settings.json hooks wired", "hooks/tezgah.json wired",
5490
+ "plugin tezgah.ts current")
4348
5491
 
4349
5492
 
4350
5493
  def arming_miss(label, good):
@@ -4376,6 +5519,8 @@ def report(hosts):
4376
5519
  ("codegen provider key (OpenRouter, DeepSeek or Inception)",
4377
5520
  tp.have_provider_key()),
4378
5521
  ("orx on PATH (OpenResearch research routing)", bool(tp.orx_bin())),
5522
+ ("ocr on PATH (open-code-review spec for the reviewer, opt-in)",
5523
+ bool(tp.ocr_bin())),
4379
5524
  ("npx on PATH (app-analysis MCP servers)", bool(shutil.which("npx"))),
4380
5525
  ("app artifacts dir %s" % tezgah_apps.ARTIFACTS,
4381
5526
  os.path.isdir(tezgah_apps.ARTIFACTS)),
@@ -4608,14 +5753,17 @@ def claude_mcp_rows(copies):
4608
5753
  return rows
4609
5754
 
4610
5755
 
4611
- def _hooks_has(path, marker, events=None):
5756
+ def _hooks_has(path, marker, events=None, bare=False, key="hooks"):
4612
5757
  """True when `marker` is wired under every event in `events`, or under any
4613
- event when `events` is None.
5758
+ event when `events` is None. `bare`: the file is keyed by event at the top
5759
+ (Droid's hooks.json); `key`: the top-level name the events sit under
5760
+ (`hooks`, or Antigravity's `tezgah`).
4614
5761
 
4615
5762
  A row that one event can satisfy answers the wrong question: a manifest
4616
5763
  carrying tezgah's command under SessionStart and not under Stop reads as
4617
5764
  wired, and Stop is the integrity refusal."""
4618
- hooks = (read_json(path, {}) or {}).get("hooks") or {}
5765
+ data = read_json(path, {}) or {}
5766
+ hooks = data if bare else data.get(key) or {}
4619
5767
  if events is None:
4620
5768
  return any(marker in json.dumps(e) for arr in hooks.values() for e in arr)
4621
5769
  return all(any(marker in json.dumps(e) for e in (hooks.get(ev) or []))
@@ -4694,6 +5842,7 @@ def host_checks_codex():
4694
5842
  def host_checks_opencode():
4695
5843
  cfg = read_json(os.path.join(OPENCODE, "opencode.json"), {})
4696
5844
  tui = read_json(os.path.join(OPENCODE, "tui.json"), {})
5845
+ cli = read_json(os.path.join(OPENCODE, "cli.json"), {})
4697
5846
  instr = cfg.get("instructions") or []
4698
5847
  specs = [p if isinstance(p, str) else p[0] for p in (tui.get("plugin") or [])]
4699
5848
  return [
@@ -4711,14 +5860,20 @@ def host_checks_opencode():
4711
5860
  ] + mcp_rows(set(cfg.get("mcp") or {}), "opencode.json") + [
4712
5861
  mcp_row(TEZGAH_MCP_NAME, (cfg.get("mcp") or {}).get(TEZGAH_MCP_NAME),
4713
5862
  "opencode.json", tezgah_mcp_command()),
5863
+ ] + ([
5864
+ ("status line plugin in cli.json + linked",
5865
+ "file://" + OPENCODE_CLI_PLUGIN in (cli.get("plugins") or [])
5866
+ and os.path.exists(OPENCODE_CLI_PLUGIN)),
5867
+ ] if opencode_v2() else [
4714
5868
  ("context pruning armed (compaction.prune)",
4715
5869
  (cfg.get("compaction") or {}).get("prune") is True),
4716
- ("watcher ignores noisy trees",
4717
- bool(set(OPENCODE_IGNORE)
4718
- & set(((cfg.get("watcher") or {}).get("ignore") or [])))),
4719
5870
  ("status line plugin in tui.json + linked",
4720
5871
  any("tezgah-tui" in s for s in specs)
4721
5872
  and os.path.exists(os.path.join(OPENCODE, "tui-plugins", "tezgah-tui.tsx"))),
5873
+ ]) + [
5874
+ ("watcher ignores noisy trees",
5875
+ bool(set(OPENCODE_IGNORE)
5876
+ & set(((cfg.get("watcher") or {}).get("ignore") or [])))),
4722
5877
  # opencode's own registry declares INCEPTION_API_KEY for the inception
4723
5878
  # provider, so nothing needs adding to opencode.json - but the route is
4724
5879
  # only usable once a key resolves, and that is what this reports.
@@ -4747,6 +5902,157 @@ def host_checks_cursor():
4747
5902
  ]
4748
5903
 
4749
5904
 
5905
+ def host_checks_qwen():
5906
+ path = os.path.join(QWEN, "settings.json")
5907
+ # a relocated home (QWEN_HOME) is named, as codex's is
5908
+ where = " in %s" % QWEN if QWEN != os.path.join(tp.HOME, ".qwen") else ""
5909
+ ui = read_json(path, {}).get("ui")
5910
+ return [
5911
+ ("settings.json hooks wired%s" % where,
5912
+ _hooks_has(path, "tezgah-qwen-hook", [ev for ev, _m, _t in QWEN_HOOK_EVENTS])),
5913
+ ("skills linked%s" % where, skills_linked(os.path.join(QWEN, "skills"))),
5914
+ ("QWEN.md carries the contract (matches a fresh render)", rules_current("qwen")),
5915
+ ("statusLine in settings.json ui",
5916
+ "tezgah-statusline" in json.dumps((ui if isinstance(ui, dict) else {})
5917
+ .get("statusLine") or {})),
5918
+ ("qwen CLI on PATH", bool(tp.which_user("qwen"))),
5919
+ ]
5920
+
5921
+
5922
+ def host_checks_droid():
5923
+ path = os.path.join(DROID, "hooks.json")
5924
+ why = droid_hooks_blocked()
5925
+ settings = read_jsonc(os.path.join(DROID, "settings.json")) or {}
5926
+ status = settings.get("statusLine")
5927
+ return [
5928
+ ("hooks.json wired" + (" (not written: %s)" % why if why else ""),
5929
+ _hooks_has(path, "tezgah-droid-hook", [ev for ev, _m, _t in DROID_HOOK_EVENTS],
5930
+ bare=True)),
5931
+ ("skills linked", skills_linked(os.path.join(DROID, "skills"))),
5932
+ ("AGENTS.md carries the contract (matches a fresh render)", rules_current("droid")),
5933
+ ("statusLine in settings.json", "tezgah-statusline" in json.dumps(status or {})),
5934
+ ("includeCoAuthoredByDroid off in settings.json",
5935
+ settings.get("includeCoAuthoredByDroid") is False),
5936
+ ("droid CLI on PATH", bool(tp.which_user("droid"))),
5937
+ ]
5938
+
5939
+
5940
+ def host_checks_commandcode():
5941
+ path = os.path.join(COMMANDCODE, "settings.json")
5942
+ attr = read_json(path, {}).get("attribution")
5943
+ return [
5944
+ ("settings.json hooks wired",
5945
+ _hooks_has(path, "tezgah-commandcode-hook",
5946
+ [ev for ev, _m, _t in COMMANDCODE_HOOK_EVENTS])),
5947
+ ("skills linked", skills_linked(os.path.join(COMMANDCODE, "skills"))),
5948
+ ("AGENTS.md carries the contract (matches a fresh render)",
5949
+ rules_current("commandcode")),
5950
+ ("attribution.commit off in settings.json",
5951
+ isinstance(attr, dict) and attr.get("commit") == ""),
5952
+ ("command-code CLI on PATH", bool(tp.which_user("command-code"))),
5953
+ ]
5954
+
5955
+
5956
+ def host_checks_copilot():
5957
+ # a relocated home (COPILOT_HOME) is named, as codex's is
5958
+ where = " in %s" % COPILOT if COPILOT != os.path.join(tp.HOME, ".copilot") else ""
5959
+ settings = read_jsonc(os.path.join(COPILOT, "settings.json")) or {}
5960
+ return [
5961
+ ("hooks/tezgah.json wired%s" % where,
5962
+ _hooks_has(os.path.join(COPILOT, "hooks", "tezgah.json"), "tezgah-copilot-hook",
5963
+ [ev for ev, _m, _t in COPILOT_HOOK_EVENTS])),
5964
+ ("skills linked%s" % where, skills_linked(os.path.join(COPILOT, "skills"))),
5965
+ ("copilot-instructions.md carries the contract (matches a fresh render)",
5966
+ rules_current("copilot")),
5967
+ ("statusLine in settings.json",
5968
+ "tezgah-statusline" in json.dumps(settings.get("statusLine") or {})),
5969
+ ("includeCoAuthoredBy off in settings.json",
5970
+ settings.get("includeCoAuthoredBy") is False),
5971
+ ("copilot CLI on PATH", bool(tp.which_user("copilot"))),
5972
+ ]
5973
+
5974
+
5975
+ def host_checks_gemini():
5976
+ # a relocated home (GEMINI_CLI_HOME) is named, as codex's is
5977
+ where = " in %s" % GEMINI if GEMINI != os.path.join(tp.HOME, ".gemini") else ""
5978
+ hooks_cfg = (read_jsonc(os.path.join(GEMINI, "settings.json")) or {}).get("hooksConfig")
5979
+ return [
5980
+ # the hooks load only beside a manifest naming the extension
5981
+ ("extension hooks/hooks.json wired%s" % where,
5982
+ _hooks_has(os.path.join(GEMINI_EXT, "hooks", "hooks.json"), "tezgah-gemini-hook",
5983
+ [ev for ev, _m, _t in GEMINI_HOOK_EVENTS])
5984
+ and read_json(os.path.join(GEMINI_EXT, "gemini-extension.json"), {})
5985
+ .get("name") == "tezgah"),
5986
+ ("skills linked%s" % where, skills_linked(os.path.join(GEMINI_EXT, "skills"))),
5987
+ ("GEMINI.md carries the contract (matches a fresh render)", rules_current("gemini")),
5988
+ # `hooksConfig.enabled: false` runs no hook at all (configuration)
5989
+ ("hooks left on in settings.json (hooksConfig.enabled)",
5990
+ not (isinstance(hooks_cfg, dict) and hooks_cfg.get("enabled") is False)),
5991
+ ("gemini CLI on PATH", bool(tp.which_user("gemini"))),
5992
+ ]
5993
+
5994
+
5995
+ def host_checks_antigravity():
5996
+ settings = read_json(os.path.join(ANTIGRAVITY, "settings.json"), {})
5997
+ mine = read_json(AGY_HOOKS, {}).get("tezgah")
5998
+ return [
5999
+ ("config/hooks.json wired (the `tezgah` name)",
6000
+ _hooks_has(AGY_HOOKS, "tezgah-antigravity-hook",
6001
+ [ev for ev, _m, _t in ANTIGRAVITY_HOOK_EVENTS], key="tezgah")),
6002
+ # `enabled: false` on a hook name loads none of its events (docs/hooks)
6003
+ ("tezgah hook set left enabled in config/hooks.json",
6004
+ not (isinstance(mine, dict) and mine.get("enabled") is False)),
6005
+ ("skills linked", skills_linked(os.path.join(ANTIGRAVITY, "skills"))),
6006
+ ("GEMINI.md carries the contract (matches a fresh render)",
6007
+ rules_current("antigravity")),
6008
+ ("statusLine in antigravity-cli/settings.json",
6009
+ "tezgah-statusline" in json.dumps(settings.get("statusLine") or {})),
6010
+ ("agy CLI on PATH", bool(tp.which_user("agy"))),
6011
+ ]
6012
+
6013
+
6014
+ def host_checks_goose():
6015
+ disabled = read_json(os.path.join(GOOSE, "settings.json"), {}).get("disabledPlugins")
6016
+ return [
6017
+ # the hooks load only from a plugin dir under ~/.agents/plugins
6018
+ ("plugin hooks/hooks.json wired (~/.agents/plugins/tezgah)",
6019
+ _hooks_has(os.path.join(GOOSE_PLUGIN, "hooks", "hooks.json"), "tezgah-goose-hook",
6020
+ [ev for ev, _m, _t in GOOSE_HOOK_EVENTS])
6021
+ and read_json(os.path.join(GOOSE_PLUGIN, "plugin.json"), {}).get("name") == "tezgah"),
6022
+ # a plugin named in `disabledPlugins` is skipped at discovery (plugins guide)
6023
+ ("tezgah plugin not in disabledPlugins (settings.json)",
6024
+ not (isinstance(disabled, list) and "tezgah" in disabled)),
6025
+ ("skills linked", skills_linked(os.path.join(GOOSE_PLUGIN, "skills"))),
6026
+ (".goosehints carries the contract (matches a fresh render)", rules_current("goose")),
6027
+ ("goose CLI on PATH", bool(tp.which_user("goose"))),
6028
+ ]
6029
+
6030
+
6031
+ def host_checks_amp():
6032
+ try:
6033
+ with open(AMP_PLUGIN, encoding="utf-8") as fh:
6034
+ plugin = fh.read()
6035
+ except OSError:
6036
+ plugin = ""
6037
+ # either user settings file; the comment-tolerant reader for both
6038
+ settings = (read_jsonc(os.path.join(AMP, "settings.json")) or {}
6039
+ if not os.path.exists(os.path.join(AMP, "settings.jsonc"))
6040
+ else read_jsonc(os.path.join(AMP, "settings.jsonc")) or {})
6041
+ return [
6042
+ # byte for byte, as omp's bridge row: a hand edit or an older tree's
6043
+ # render is not the wiring this tree arms
6044
+ ("plugin tezgah.ts current (matches a fresh render): %s" % AMP_PLUGIN,
6045
+ bool(plugin) and plugin == ta.amp_plugin(HERE)),
6046
+ ("skills linked", skills_linked(os.path.join(AMP, "skills"))),
6047
+ ("AGENTS.md carries the contract (matches a fresh render)", rules_current("amp")),
6048
+ ("%s off in settings.json" % AMP_ATTRIBUTION[0],
6049
+ settings.get(AMP_ATTRIBUTION[0]) is False),
6050
+ ("%s off in settings.json" % AMP_ATTRIBUTION[1],
6051
+ settings.get(AMP_ATTRIBUTION[1]) is False),
6052
+ ("amp CLI on PATH", bool(tp.which_user("amp"))),
6053
+ ]
6054
+
6055
+
4750
6056
  def host_checks_dsh():
4751
6057
  try:
4752
6058
  with open(DSH_PATCH, encoding="utf-8") as fh:
@@ -4865,7 +6171,11 @@ def host_checks_omp():
4865
6171
 
4866
6172
  HOST_CHECKS = {"claude": host_checks_claude, "codex": host_checks_codex,
4867
6173
  "opencode": host_checks_opencode, "cursor": host_checks_cursor,
4868
- "dsh": host_checks_dsh, "omp": host_checks_omp}
6174
+ "dsh": host_checks_dsh, "omp": host_checks_omp, "qwen": host_checks_qwen,
6175
+ "droid": host_checks_droid, "commandcode": host_checks_commandcode,
6176
+ "copilot": host_checks_copilot, "gemini": host_checks_gemini,
6177
+ "antigravity": host_checks_antigravity, "goose": host_checks_goose,
6178
+ "amp": host_checks_amp}
4869
6179
 
4870
6180
 
4871
6181
  def versions_agree():
@@ -5923,7 +7233,7 @@ def hosts_row():
5923
7233
  host's first HOST_CHECKS row), as one report row. Reported, never changed:
5924
7234
  `--upgrade`, `--enable`/`--disable` and a full uninstall act on the recorded
5925
7235
  list, so a host wired but not recorded is one they skip, and which list is
5926
- right is the user's call (`--install --hosts ...` records it)."""
7236
+ right is the user's call (`--install --hosts ...` adds to it)."""
5927
7237
  recorded = sorted(tp.config().get("hosts") or [])
5928
7238
  wired = sorted(h for h in ALL_HOSTS if HOST_CHECKS[h]()[0][1])
5929
7239
  return ("config.json hosts (%s) match the hosts wired (%s)"