@r1z4x/tezgah 1.0.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 (582) hide show
  1. package/CHANGELOG.md +738 -0
  2. package/LICENSE +21 -0
  3. package/MANIFEST +753 -0
  4. package/README.de.md +169 -0
  5. package/README.es.md +168 -0
  6. package/README.fr.md +166 -0
  7. package/README.md +161 -0
  8. package/README.tr.md +163 -0
  9. package/agents/tezgah-reviewer.md +70 -0
  10. package/bin/codegen +337 -0
  11. package/bin/consult +797 -0
  12. package/bin/tezgah-adhd +50 -0
  13. package/bin/tezgah-agents +45 -0
  14. package/bin/tezgah-capture +66 -0
  15. package/bin/tezgah-context +96 -0
  16. package/bin/tezgah-decisions +213 -0
  17. package/bin/tezgah-design +953 -0
  18. package/bin/tezgah-docs +1199 -0
  19. package/bin/tezgah-doctor +497 -0
  20. package/bin/tezgah-dsh +37 -0
  21. package/bin/tezgah-dsh.cmd +70 -0
  22. package/bin/tezgah-gate +75 -0
  23. package/bin/tezgah-import-ai-research +439 -0
  24. package/bin/tezgah-index +32 -0
  25. package/bin/tezgah-lessons +148 -0
  26. package/bin/tezgah-mcp +322 -0
  27. package/bin/tezgah-migrate +324 -0
  28. package/bin/tezgah-pony +50 -0
  29. package/bin/tezgah-research +772 -0
  30. package/bin/tezgah-rollback +93 -0
  31. package/bin/tezgah-route +124 -0
  32. package/bin/tezgah-setup +5798 -0
  33. package/bin/tezgah-status +383 -0
  34. package/bin/tezgah-task +607 -0
  35. package/bin/tezgah-taste +423 -0
  36. package/bin/tezgah-triage +485 -0
  37. package/bin/tezgah.js +36 -0
  38. package/commands/adhd.md +21 -0
  39. package/commands/ponytail.md +21 -0
  40. package/docs/README.md +112 -0
  41. package/docs/architecture.md +226 -0
  42. package/docs/citations-baseline.json +59 -0
  43. package/docs/clarity-baseline.json +116 -0
  44. package/docs/contract.md +416 -0
  45. package/docs/environment.md +141 -0
  46. package/docs/evidence.md +831 -0
  47. package/docs/feature-audit.md +139 -0
  48. package/docs/gate.md +407 -0
  49. package/docs/glossary.md +201 -0
  50. package/docs/hosts.md +400 -0
  51. package/docs/index.json +471 -0
  52. package/docs/judge.md +193 -0
  53. package/docs/layers.md +189 -0
  54. package/docs/models.md +160 -0
  55. package/docs/operations.md +666 -0
  56. package/docs/research.md +809 -0
  57. package/docs/results/paired-outcome-2026-10.jsonl +254 -0
  58. package/docs/results/paired-outcome-2026-10.md +204 -0
  59. package/docs/skills.md +236 -0
  60. package/docs/status-line.md +315 -0
  61. package/docs/testing.md +287 -0
  62. package/docs/vision.md +117 -0
  63. package/hooks/hooks.json +33 -0
  64. package/hooks/projects-auto-init.py +98 -0
  65. package/hooks/projects-posttooluse.py +149 -0
  66. package/hooks/projects-pretooluse.py +40 -0
  67. package/hooks/projects-stop.py +60 -0
  68. package/hooks/tezgah_agents.py +817 -0
  69. package/hooks/tezgah_apps.py +266 -0
  70. package/hooks/tezgah_attest.py +255 -0
  71. package/hooks/tezgah_clarity.py +171 -0
  72. package/hooks/tezgah_components.py +140 -0
  73. package/hooks/tezgah_context.py +2953 -0
  74. package/hooks/tezgah_embed.py +765 -0
  75. package/hooks/tezgah_gate.py +2736 -0
  76. package/hooks/tezgah_guard.py +177 -0
  77. package/hooks/tezgah_index.py +124 -0
  78. package/hooks/tezgah_integrity.py +5141 -0
  79. package/hooks/tezgah_judge.py +487 -0
  80. package/hooks/tezgah_lang.py +230 -0
  81. package/hooks/tezgah_lessons.py +210 -0
  82. package/hooks/tezgah_models.py +855 -0
  83. package/hooks/tezgah_paths.py +1132 -0
  84. package/hooks/tezgah_policy.py +1039 -0
  85. package/hooks/tezgah_rank.py +77 -0
  86. package/hooks/tezgah_replay.py +1109 -0
  87. package/hooks/tezgah_research.py +5176 -0
  88. package/hooks/tezgah_shapes.py +305 -0
  89. package/hooks/tezgah_skill_pick.py +227 -0
  90. package/hooks/tezgah_snapshot.py +490 -0
  91. package/hooks/tezgah_stopspec.py +320 -0
  92. package/hooks/tezgah_task.py +458 -0
  93. package/hooks/tezgah_taste.py +201 -0
  94. package/hooks/tezgah_untrusted.py +112 -0
  95. package/hooks/tezgah_update.py +430 -0
  96. package/hosts/codex/hook.py +268 -0
  97. package/hosts/codex/hooks.json +26 -0
  98. package/hosts/cursor/hook.py +461 -0
  99. package/hosts/cursor/hooks.json +44 -0
  100. package/hosts/dsh/hooks.json +21 -0
  101. package/hosts/dsh/statusline/lib/client.js +138 -0
  102. package/hosts/dsh/statusline/lib/index.js +104 -0
  103. package/hosts/dsh/statusline/package.json +28 -0
  104. package/hosts/omp/hook.py +277 -0
  105. package/hosts/omp/tezgah-hook.ts.in +701 -0
  106. package/hosts/opencode/plugins/tezgah.js +2742 -0
  107. package/hosts/opencode/tui/tezgah-tui.tsx +151 -0
  108. package/output-styles/tezgah.md +142 -0
  109. package/package.json +32 -0
  110. package/packaging/build.sh +342 -0
  111. package/packaging/install.ps1 +166 -0
  112. package/packaging/install.sh +56 -0
  113. package/packaging/upgrade.sh +142 -0
  114. package/skills/ai-research/0-autoresearch-skill/SKILL.md +411 -0
  115. package/skills/ai-research/0-autoresearch-skill/references/agent-continuity.md +95 -0
  116. package/skills/ai-research/0-autoresearch-skill/references/progress-reporting.md +166 -0
  117. package/skills/ai-research/0-autoresearch-skill/references/skill-routing.md +219 -0
  118. package/skills/ai-research/0-autoresearch-skill/templates/findings.md +44 -0
  119. package/skills/ai-research/0-autoresearch-skill/templates/research-log.md +41 -0
  120. package/skills/ai-research/01-model-architecture/litgpt/SKILL.md +469 -0
  121. package/skills/ai-research/01-model-architecture/litgpt/references/custom-models.md +569 -0
  122. package/skills/ai-research/01-model-architecture/litgpt/references/distributed-training.md +452 -0
  123. package/skills/ai-research/01-model-architecture/litgpt/references/supported-models.md +337 -0
  124. package/skills/ai-research/01-model-architecture/litgpt/references/training-recipes.md +620 -0
  125. package/skills/ai-research/01-model-architecture/mamba/SKILL.md +260 -0
  126. package/skills/ai-research/01-model-architecture/mamba/references/architecture-details.md +207 -0
  127. package/skills/ai-research/01-model-architecture/mamba/references/benchmarks.md +256 -0
  128. package/skills/ai-research/01-model-architecture/mamba/references/training-guide.md +389 -0
  129. package/skills/ai-research/01-model-architecture/nanogpt/SKILL.md +290 -0
  130. package/skills/ai-research/01-model-architecture/nanogpt/references/architecture.md +383 -0
  131. package/skills/ai-research/01-model-architecture/nanogpt/references/data.md +477 -0
  132. package/skills/ai-research/01-model-architecture/nanogpt/references/training.md +565 -0
  133. package/skills/ai-research/01-model-architecture/rwkv/SKILL.md +260 -0
  134. package/skills/ai-research/01-model-architecture/rwkv/references/architecture-details.md +345 -0
  135. package/skills/ai-research/01-model-architecture/rwkv/references/rwkv7.md +387 -0
  136. package/skills/ai-research/01-model-architecture/rwkv/references/state-management.md +370 -0
  137. package/skills/ai-research/01-model-architecture/torchtitan/SKILL.md +358 -0
  138. package/skills/ai-research/01-model-architecture/torchtitan/references/checkpoint.md +182 -0
  139. package/skills/ai-research/01-model-architecture/torchtitan/references/custom-models.md +259 -0
  140. package/skills/ai-research/01-model-architecture/torchtitan/references/float8.md +134 -0
  141. package/skills/ai-research/01-model-architecture/torchtitan/references/fsdp.md +127 -0
  142. package/skills/ai-research/02-tokenization/huggingface-tokenizers/SKILL.md +516 -0
  143. package/skills/ai-research/02-tokenization/huggingface-tokenizers/references/algorithms.md +654 -0
  144. package/skills/ai-research/02-tokenization/huggingface-tokenizers/references/integration.md +638 -0
  145. package/skills/ai-research/02-tokenization/huggingface-tokenizers/references/pipeline.md +724 -0
  146. package/skills/ai-research/02-tokenization/huggingface-tokenizers/references/training.md +566 -0
  147. package/skills/ai-research/02-tokenization/sentencepiece/SKILL.md +235 -0
  148. package/skills/ai-research/02-tokenization/sentencepiece/references/algorithms.md +201 -0
  149. package/skills/ai-research/02-tokenization/sentencepiece/references/training.md +305 -0
  150. package/skills/ai-research/03-fine-tuning/axolotl/SKILL.md +158 -0
  151. package/skills/ai-research/03-fine-tuning/axolotl/references/api.md +5549 -0
  152. package/skills/ai-research/03-fine-tuning/axolotl/references/dataset-formats.md +1030 -0
  153. package/skills/ai-research/03-fine-tuning/axolotl/references/index.md +16 -0
  154. package/skills/ai-research/03-fine-tuning/axolotl/references/other.md +3564 -0
  155. package/skills/ai-research/03-fine-tuning/llama-factory/SKILL.md +80 -0
  156. package/skills/ai-research/03-fine-tuning/llama-factory/references/_images.md +24 -0
  157. package/skills/ai-research/03-fine-tuning/llama-factory/references/advanced.md +1056 -0
  158. package/skills/ai-research/03-fine-tuning/llama-factory/references/getting_started.md +350 -0
  159. package/skills/ai-research/03-fine-tuning/llama-factory/references/index.md +20 -0
  160. package/skills/ai-research/03-fine-tuning/llama-factory/references/other.md +32 -0
  161. package/skills/ai-research/03-fine-tuning/peft/SKILL.md +431 -0
  162. package/skills/ai-research/03-fine-tuning/peft/references/advanced-usage.md +515 -0
  163. package/skills/ai-research/03-fine-tuning/peft/references/troubleshooting.md +481 -0
  164. package/skills/ai-research/03-fine-tuning/unsloth/SKILL.md +80 -0
  165. package/skills/ai-research/03-fine-tuning/unsloth/references/index.md +8 -0
  166. package/skills/ai-research/03-fine-tuning/unsloth/references/llms.md +83 -0
  167. package/skills/ai-research/04-mechanistic-interpretability/nnsight/SKILL.md +436 -0
  168. package/skills/ai-research/04-mechanistic-interpretability/nnsight/references/README.md +79 -0
  169. package/skills/ai-research/04-mechanistic-interpretability/nnsight/references/api.md +345 -0
  170. package/skills/ai-research/04-mechanistic-interpretability/nnsight/references/tutorials.md +301 -0
  171. package/skills/ai-research/04-mechanistic-interpretability/pyvene/SKILL.md +473 -0
  172. package/skills/ai-research/04-mechanistic-interpretability/pyvene/references/README.md +74 -0
  173. package/skills/ai-research/04-mechanistic-interpretability/pyvene/references/api.md +384 -0
  174. package/skills/ai-research/04-mechanistic-interpretability/pyvene/references/tutorials.md +377 -0
  175. package/skills/ai-research/04-mechanistic-interpretability/saelens/SKILL.md +386 -0
  176. package/skills/ai-research/04-mechanistic-interpretability/saelens/references/README.md +71 -0
  177. package/skills/ai-research/04-mechanistic-interpretability/saelens/references/api.md +334 -0
  178. package/skills/ai-research/04-mechanistic-interpretability/saelens/references/tutorials.md +319 -0
  179. package/skills/ai-research/04-mechanistic-interpretability/transformer-lens/SKILL.md +346 -0
  180. package/skills/ai-research/04-mechanistic-interpretability/transformer-lens/references/README.md +55 -0
  181. package/skills/ai-research/04-mechanistic-interpretability/transformer-lens/references/api.md +363 -0
  182. package/skills/ai-research/04-mechanistic-interpretability/transformer-lens/references/tutorials.md +340 -0
  183. package/skills/ai-research/05-data-processing/nemo-curator/SKILL.md +383 -0
  184. package/skills/ai-research/05-data-processing/nemo-curator/references/deduplication.md +88 -0
  185. package/skills/ai-research/05-data-processing/nemo-curator/references/filtering.md +103 -0
  186. package/skills/ai-research/05-data-processing/ray-data/SKILL.md +326 -0
  187. package/skills/ai-research/05-data-processing/ray-data/references/integration.md +83 -0
  188. package/skills/ai-research/05-data-processing/ray-data/references/transformations.md +84 -0
  189. package/skills/ai-research/06-post-training/grpo-rl-training/README.md +98 -0
  190. package/skills/ai-research/06-post-training/grpo-rl-training/SKILL.md +572 -0
  191. package/skills/ai-research/06-post-training/miles/SKILL.md +315 -0
  192. package/skills/ai-research/06-post-training/miles/references/api-reference.md +142 -0
  193. package/skills/ai-research/06-post-training/miles/references/troubleshooting.md +353 -0
  194. package/skills/ai-research/06-post-training/openrlhf/SKILL.md +249 -0
  195. package/skills/ai-research/06-post-training/openrlhf/references/algorithm-comparison.md +405 -0
  196. package/skills/ai-research/06-post-training/openrlhf/references/custom-rewards.md +531 -0
  197. package/skills/ai-research/06-post-training/openrlhf/references/hybrid-engine.md +288 -0
  198. package/skills/ai-research/06-post-training/openrlhf/references/multi-node-training.md +455 -0
  199. package/skills/ai-research/06-post-training/simpo/SKILL.md +219 -0
  200. package/skills/ai-research/06-post-training/simpo/references/datasets.md +479 -0
  201. package/skills/ai-research/06-post-training/simpo/references/hyperparameters.md +453 -0
  202. package/skills/ai-research/06-post-training/simpo/references/loss-functions.md +351 -0
  203. package/skills/ai-research/06-post-training/slime/SKILL.md +464 -0
  204. package/skills/ai-research/06-post-training/slime/references/api-reference.md +393 -0
  205. package/skills/ai-research/06-post-training/slime/references/troubleshooting.md +387 -0
  206. package/skills/ai-research/06-post-training/torchforge/SKILL.md +433 -0
  207. package/skills/ai-research/06-post-training/torchforge/references/api-reference.md +328 -0
  208. package/skills/ai-research/06-post-training/torchforge/references/troubleshooting.md +410 -0
  209. package/skills/ai-research/06-post-training/trl-fine-tuning/SKILL.md +455 -0
  210. package/skills/ai-research/06-post-training/trl-fine-tuning/references/dpo-variants.md +228 -0
  211. package/skills/ai-research/06-post-training/trl-fine-tuning/references/online-rl.md +83 -0
  212. package/skills/ai-research/06-post-training/trl-fine-tuning/references/reward-modeling.md +123 -0
  213. package/skills/ai-research/06-post-training/trl-fine-tuning/references/sft-training.md +169 -0
  214. package/skills/ai-research/06-post-training/verl/SKILL.md +391 -0
  215. package/skills/ai-research/06-post-training/verl/references/api-reference.md +302 -0
  216. package/skills/ai-research/06-post-training/verl/references/troubleshooting.md +392 -0
  217. package/skills/ai-research/07-safety-alignment/constitutional-ai/SKILL.md +290 -0
  218. package/skills/ai-research/07-safety-alignment/llamaguard/SKILL.md +337 -0
  219. package/skills/ai-research/07-safety-alignment/nemo-guardrails/SKILL.md +297 -0
  220. package/skills/ai-research/07-safety-alignment/prompt-guard/SKILL.md +312 -0
  221. package/skills/ai-research/08-distributed-training/accelerate/SKILL.md +332 -0
  222. package/skills/ai-research/08-distributed-training/accelerate/references/custom-plugins.md +454 -0
  223. package/skills/ai-research/08-distributed-training/accelerate/references/megatron-integration.md +490 -0
  224. package/skills/ai-research/08-distributed-training/accelerate/references/performance.md +526 -0
  225. package/skills/ai-research/08-distributed-training/deepspeed/SKILL.md +141 -0
  226. package/skills/ai-research/08-distributed-training/deepspeed/references/08.md +18 -0
  227. package/skills/ai-research/08-distributed-training/deepspeed/references/09.md +174 -0
  228. package/skills/ai-research/08-distributed-training/deepspeed/references/2020.md +379 -0
  229. package/skills/ai-research/08-distributed-training/deepspeed/references/2023.md +280 -0
  230. package/skills/ai-research/08-distributed-training/deepspeed/references/assets.md +180 -0
  231. package/skills/ai-research/08-distributed-training/deepspeed/references/index.md +36 -0
  232. package/skills/ai-research/08-distributed-training/deepspeed/references/mii.md +119 -0
  233. package/skills/ai-research/08-distributed-training/deepspeed/references/other.md +1192 -0
  234. package/skills/ai-research/08-distributed-training/megatron-core/SKILL.md +366 -0
  235. package/skills/ai-research/08-distributed-training/megatron-core/references/benchmarks.md +250 -0
  236. package/skills/ai-research/08-distributed-training/megatron-core/references/parallelism-guide.md +405 -0
  237. package/skills/ai-research/08-distributed-training/megatron-core/references/production-examples.md +474 -0
  238. package/skills/ai-research/08-distributed-training/megatron-core/references/training-recipes.md +548 -0
  239. package/skills/ai-research/08-distributed-training/pytorch-fsdp2/SKILL.md +231 -0
  240. package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_dcp_async_recipe.md +14 -0
  241. package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_dcp_overview.md +19 -0
  242. package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_dcp_recipe.md +18 -0
  243. package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_ddp_notes.md +10 -0
  244. package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_device_mesh_tutorial.md +22 -0
  245. package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_examples_fsdp2.md +15 -0
  246. package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_fsdp1_api.md +9 -0
  247. package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_fsdp2_tutorial.md +45 -0
  248. package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_fully_shard_api.md +49 -0
  249. package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/pytorch_tp_tutorial.md +22 -0
  250. package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/ray_train_fsdp2_example.md +13 -0
  251. package/skills/ai-research/08-distributed-training/pytorch-fsdp2/references/torchtitan_fsdp_notes.md +14 -0
  252. package/skills/ai-research/08-distributed-training/pytorch-lightning/SKILL.md +346 -0
  253. package/skills/ai-research/08-distributed-training/pytorch-lightning/references/callbacks.md +437 -0
  254. package/skills/ai-research/08-distributed-training/pytorch-lightning/references/distributed.md +491 -0
  255. package/skills/ai-research/08-distributed-training/pytorch-lightning/references/hyperparameter-tuning.md +557 -0
  256. package/skills/ai-research/08-distributed-training/ray-train/SKILL.md +406 -0
  257. package/skills/ai-research/08-distributed-training/ray-train/references/multi-node.md +629 -0
  258. package/skills/ai-research/09-infrastructure/lambda-labs/SKILL.md +545 -0
  259. package/skills/ai-research/09-infrastructure/lambda-labs/references/advanced-usage.md +612 -0
  260. package/skills/ai-research/09-infrastructure/lambda-labs/references/troubleshooting.md +531 -0
  261. package/skills/ai-research/09-infrastructure/modal/SKILL.md +341 -0
  262. package/skills/ai-research/09-infrastructure/modal/references/advanced-usage.md +504 -0
  263. package/skills/ai-research/09-infrastructure/modal/references/troubleshooting.md +495 -0
  264. package/skills/ai-research/09-infrastructure/skypilot/SKILL.md +509 -0
  265. package/skills/ai-research/09-infrastructure/skypilot/references/advanced-usage.md +492 -0
  266. package/skills/ai-research/09-infrastructure/skypilot/references/troubleshooting.md +571 -0
  267. package/skills/ai-research/10-optimization/awq/SKILL.md +310 -0
  268. package/skills/ai-research/10-optimization/awq/references/advanced-usage.md +325 -0
  269. package/skills/ai-research/10-optimization/awq/references/troubleshooting.md +345 -0
  270. package/skills/ai-research/10-optimization/bitsandbytes/SKILL.md +411 -0
  271. package/skills/ai-research/10-optimization/bitsandbytes/references/memory-optimization.md +522 -0
  272. package/skills/ai-research/10-optimization/bitsandbytes/references/qlora-training.md +522 -0
  273. package/skills/ai-research/10-optimization/bitsandbytes/references/quantization-formats.md +448 -0
  274. package/skills/ai-research/10-optimization/flash-attention/SKILL.md +367 -0
  275. package/skills/ai-research/10-optimization/flash-attention/references/benchmarks.md +216 -0
  276. package/skills/ai-research/10-optimization/flash-attention/references/transformers-integration.md +294 -0
  277. package/skills/ai-research/10-optimization/gguf/SKILL.md +427 -0
  278. package/skills/ai-research/10-optimization/gguf/references/advanced-usage.md +505 -0
  279. package/skills/ai-research/10-optimization/gguf/references/troubleshooting.md +443 -0
  280. package/skills/ai-research/10-optimization/gptq/SKILL.md +450 -0
  281. package/skills/ai-research/10-optimization/gptq/references/calibration.md +338 -0
  282. package/skills/ai-research/10-optimization/gptq/references/integration.md +130 -0
  283. package/skills/ai-research/10-optimization/gptq/references/troubleshooting.md +96 -0
  284. package/skills/ai-research/10-optimization/hqq/SKILL.md +445 -0
  285. package/skills/ai-research/10-optimization/hqq/references/advanced-usage.md +529 -0
  286. package/skills/ai-research/10-optimization/hqq/references/troubleshooting.md +504 -0
  287. package/skills/ai-research/10-optimization/ml-training-recipes/SKILL.md +319 -0
  288. package/skills/ai-research/10-optimization/ml-training-recipes/references/architecture.md +320 -0
  289. package/skills/ai-research/10-optimization/ml-training-recipes/references/biomedical.md +498 -0
  290. package/skills/ai-research/10-optimization/ml-training-recipes/references/domain-specific.md +626 -0
  291. package/skills/ai-research/10-optimization/ml-training-recipes/references/experiment-loop.md +129 -0
  292. package/skills/ai-research/10-optimization/ml-training-recipes/references/optimizers.md +330 -0
  293. package/skills/ai-research/10-optimization/ml-training-recipes/references/scaling-and-selection.md +428 -0
  294. package/skills/ai-research/11-evaluation/bigcode-evaluation-harness/SKILL.md +405 -0
  295. package/skills/ai-research/11-evaluation/bigcode-evaluation-harness/references/benchmarks.md +394 -0
  296. package/skills/ai-research/11-evaluation/bigcode-evaluation-harness/references/custom-tasks.md +425 -0
  297. package/skills/ai-research/11-evaluation/bigcode-evaluation-harness/references/issues.md +395 -0
  298. package/skills/ai-research/11-evaluation/lm-evaluation-harness/SKILL.md +490 -0
  299. package/skills/ai-research/11-evaluation/lm-evaluation-harness/references/api-evaluation.md +491 -0
  300. package/skills/ai-research/11-evaluation/lm-evaluation-harness/references/benchmark-guide.md +489 -0
  301. package/skills/ai-research/11-evaluation/lm-evaluation-harness/references/custom-tasks.md +603 -0
  302. package/skills/ai-research/11-evaluation/lm-evaluation-harness/references/distributed-eval.md +520 -0
  303. package/skills/ai-research/11-evaluation/nemo-evaluator/SKILL.md +494 -0
  304. package/skills/ai-research/11-evaluation/nemo-evaluator/references/adapter-system.md +341 -0
  305. package/skills/ai-research/11-evaluation/nemo-evaluator/references/configuration.md +448 -0
  306. package/skills/ai-research/11-evaluation/nemo-evaluator/references/custom-benchmarks.md +316 -0
  307. package/skills/ai-research/11-evaluation/nemo-evaluator/references/execution-backends.md +362 -0
  308. package/skills/ai-research/12-inference-serving/llama-cpp/SKILL.md +258 -0
  309. package/skills/ai-research/12-inference-serving/llama-cpp/references/optimization.md +90 -0
  310. package/skills/ai-research/12-inference-serving/llama-cpp/references/quantization.md +214 -0
  311. package/skills/ai-research/12-inference-serving/llama-cpp/references/server.md +126 -0
  312. package/skills/ai-research/12-inference-serving/sglang/SKILL.md +442 -0
  313. package/skills/ai-research/12-inference-serving/sglang/references/deployment.md +491 -0
  314. package/skills/ai-research/12-inference-serving/sglang/references/radix-attention.md +414 -0
  315. package/skills/ai-research/12-inference-serving/sglang/references/structured-generation.md +542 -0
  316. package/skills/ai-research/12-inference-serving/tensorrt-llm/SKILL.md +187 -0
  317. package/skills/ai-research/12-inference-serving/tensorrt-llm/references/multi-gpu.md +299 -0
  318. package/skills/ai-research/12-inference-serving/tensorrt-llm/references/optimization.md +243 -0
  319. package/skills/ai-research/12-inference-serving/tensorrt-llm/references/serving.md +471 -0
  320. package/skills/ai-research/12-inference-serving/vllm/SKILL.md +364 -0
  321. package/skills/ai-research/12-inference-serving/vllm/references/optimization.md +227 -0
  322. package/skills/ai-research/12-inference-serving/vllm/references/quantization.md +285 -0
  323. package/skills/ai-research/12-inference-serving/vllm/references/server-deployment.md +256 -0
  324. package/skills/ai-research/12-inference-serving/vllm/references/troubleshooting.md +448 -0
  325. package/skills/ai-research/13-mlops/mlflow/SKILL.md +704 -0
  326. package/skills/ai-research/13-mlops/mlflow/references/deployment.md +745 -0
  327. package/skills/ai-research/13-mlops/mlflow/references/model-registry.md +771 -0
  328. package/skills/ai-research/13-mlops/mlflow/references/tracking.md +681 -0
  329. package/skills/ai-research/13-mlops/swanlab/SKILL.md +406 -0
  330. package/skills/ai-research/13-mlops/swanlab/references/integrations.md +305 -0
  331. package/skills/ai-research/13-mlops/swanlab/references/visualization.md +234 -0
  332. package/skills/ai-research/13-mlops/tensorboard/SKILL.md +629 -0
  333. package/skills/ai-research/13-mlops/tensorboard/references/integrations.md +639 -0
  334. package/skills/ai-research/13-mlops/tensorboard/references/profiling.md +546 -0
  335. package/skills/ai-research/13-mlops/tensorboard/references/visualization.md +621 -0
  336. package/skills/ai-research/13-mlops/weights-and-biases/SKILL.md +590 -0
  337. package/skills/ai-research/13-mlops/weights-and-biases/references/artifacts.md +585 -0
  338. package/skills/ai-research/13-mlops/weights-and-biases/references/integrations.md +701 -0
  339. package/skills/ai-research/13-mlops/weights-and-biases/references/sweeps.md +848 -0
  340. package/skills/ai-research/14-agents/a-evolve/SKILL.md +383 -0
  341. package/skills/ai-research/14-agents/a-evolve/references/README.md +831 -0
  342. package/skills/ai-research/14-agents/a-evolve/references/api.md +594 -0
  343. package/skills/ai-research/14-agents/a-evolve/references/architecture.md +417 -0
  344. package/skills/ai-research/14-agents/a-evolve/references/design-patterns.md +462 -0
  345. package/skills/ai-research/14-agents/a-evolve/references/examples.md +352 -0
  346. package/skills/ai-research/14-agents/a-evolve/references/issues.md +591 -0
  347. package/skills/ai-research/14-agents/a-evolve/references/releases.md +112 -0
  348. package/skills/ai-research/14-agents/a-evolve/references/tutorials.md +819 -0
  349. package/skills/ai-research/14-agents/autogpt/SKILL.md +403 -0
  350. package/skills/ai-research/14-agents/autogpt/references/advanced-usage.md +536 -0
  351. package/skills/ai-research/14-agents/autogpt/references/troubleshooting.md +421 -0
  352. package/skills/ai-research/14-agents/crewai/SKILL.md +498 -0
  353. package/skills/ai-research/14-agents/crewai/references/flows.md +439 -0
  354. package/skills/ai-research/14-agents/crewai/references/tools.md +430 -0
  355. package/skills/ai-research/14-agents/crewai/references/troubleshooting.md +481 -0
  356. package/skills/ai-research/14-agents/langchain/SKILL.md +480 -0
  357. package/skills/ai-research/14-agents/langchain/references/agents.md +500 -0
  358. package/skills/ai-research/14-agents/langchain/references/integration.md +563 -0
  359. package/skills/ai-research/14-agents/langchain/references/rag.md +601 -0
  360. package/skills/ai-research/14-agents/llamaindex/SKILL.md +569 -0
  361. package/skills/ai-research/14-agents/llamaindex/references/agents.md +84 -0
  362. package/skills/ai-research/14-agents/llamaindex/references/data_connectors.md +109 -0
  363. package/skills/ai-research/14-agents/llamaindex/references/query_engines.md +407 -0
  364. package/skills/ai-research/15-rag/chroma/SKILL.md +406 -0
  365. package/skills/ai-research/15-rag/chroma/references/integration.md +39 -0
  366. package/skills/ai-research/15-rag/faiss/SKILL.md +221 -0
  367. package/skills/ai-research/15-rag/faiss/references/index_types.md +281 -0
  368. package/skills/ai-research/15-rag/pinecone/SKILL.md +358 -0
  369. package/skills/ai-research/15-rag/pinecone/references/deployment.md +182 -0
  370. package/skills/ai-research/15-rag/qdrant/SKILL.md +493 -0
  371. package/skills/ai-research/15-rag/qdrant/references/advanced-usage.md +649 -0
  372. package/skills/ai-research/15-rag/qdrant/references/troubleshooting.md +632 -0
  373. package/skills/ai-research/15-rag/sentence-transformers/SKILL.md +255 -0
  374. package/skills/ai-research/15-rag/sentence-transformers/references/models.md +124 -0
  375. package/skills/ai-research/16-prompt-engineering/dspy/SKILL.md +590 -0
  376. package/skills/ai-research/16-prompt-engineering/dspy/references/examples.md +664 -0
  377. package/skills/ai-research/16-prompt-engineering/dspy/references/modules.md +476 -0
  378. package/skills/ai-research/16-prompt-engineering/dspy/references/optimizers.md +567 -0
  379. package/skills/ai-research/16-prompt-engineering/guidance/SKILL.md +572 -0
  380. package/skills/ai-research/16-prompt-engineering/guidance/references/backends.md +555 -0
  381. package/skills/ai-research/16-prompt-engineering/guidance/references/constraints.md +675 -0
  382. package/skills/ai-research/16-prompt-engineering/guidance/references/examples.md +768 -0
  383. package/skills/ai-research/16-prompt-engineering/instructor/SKILL.md +740 -0
  384. package/skills/ai-research/16-prompt-engineering/instructor/references/examples.md +108 -0
  385. package/skills/ai-research/16-prompt-engineering/instructor/references/providers.md +71 -0
  386. package/skills/ai-research/16-prompt-engineering/instructor/references/validation.md +607 -0
  387. package/skills/ai-research/16-prompt-engineering/outlines/SKILL.md +652 -0
  388. package/skills/ai-research/16-prompt-engineering/outlines/references/backends.md +616 -0
  389. package/skills/ai-research/16-prompt-engineering/outlines/references/examples.md +774 -0
  390. package/skills/ai-research/16-prompt-engineering/outlines/references/json_generation.md +653 -0
  391. package/skills/ai-research/17-observability/langsmith/SKILL.md +422 -0
  392. package/skills/ai-research/17-observability/langsmith/references/advanced-usage.md +549 -0
  393. package/skills/ai-research/17-observability/langsmith/references/troubleshooting.md +538 -0
  394. package/skills/ai-research/17-observability/phoenix/SKILL.md +475 -0
  395. package/skills/ai-research/17-observability/phoenix/references/advanced-usage.md +620 -0
  396. package/skills/ai-research/17-observability/phoenix/references/troubleshooting.md +539 -0
  397. package/skills/ai-research/18-multimodal/audiocraft/SKILL.md +564 -0
  398. package/skills/ai-research/18-multimodal/audiocraft/references/advanced-usage.md +667 -0
  399. package/skills/ai-research/18-multimodal/audiocraft/references/troubleshooting.md +505 -0
  400. package/skills/ai-research/18-multimodal/blip-2/SKILL.md +564 -0
  401. package/skills/ai-research/18-multimodal/blip-2/references/advanced-usage.md +681 -0
  402. package/skills/ai-research/18-multimodal/blip-2/references/troubleshooting.md +527 -0
  403. package/skills/ai-research/18-multimodal/clip/SKILL.md +253 -0
  404. package/skills/ai-research/18-multimodal/clip/references/applications.md +208 -0
  405. package/skills/ai-research/18-multimodal/cosmos-policy/SKILL.md +402 -0
  406. package/skills/ai-research/18-multimodal/cosmos-policy/references/libero-commands.md +175 -0
  407. package/skills/ai-research/18-multimodal/cosmos-policy/references/robocasa-commands.md +147 -0
  408. package/skills/ai-research/18-multimodal/llava/SKILL.md +304 -0
  409. package/skills/ai-research/18-multimodal/llava/references/training.md +198 -0
  410. package/skills/ai-research/18-multimodal/openpi/SKILL.md +480 -0
  411. package/skills/ai-research/18-multimodal/openpi/references/checkpoints-and-env-map.md +86 -0
  412. package/skills/ai-research/18-multimodal/openpi/references/config-recipes.md +50 -0
  413. package/skills/ai-research/18-multimodal/openpi/references/pytorch-gotchas.md +49 -0
  414. package/skills/ai-research/18-multimodal/openpi/references/remote-client-pattern.md +48 -0
  415. package/skills/ai-research/18-multimodal/openpi/references/training-debugging.md +46 -0
  416. package/skills/ai-research/18-multimodal/openvla-oft/SKILL.md +441 -0
  417. package/skills/ai-research/18-multimodal/openvla-oft/references/aloha-workflow.md +141 -0
  418. package/skills/ai-research/18-multimodal/openvla-oft/references/config-troubleshooting.md +97 -0
  419. package/skills/ai-research/18-multimodal/openvla-oft/references/libero-workflow.md +115 -0
  420. package/skills/ai-research/18-multimodal/openvla-oft/references/paper-and-checkpoints.md +84 -0
  421. package/skills/ai-research/18-multimodal/segment-anything/SKILL.md +500 -0
  422. package/skills/ai-research/18-multimodal/segment-anything/references/advanced-usage.md +590 -0
  423. package/skills/ai-research/18-multimodal/segment-anything/references/troubleshooting.md +485 -0
  424. package/skills/ai-research/18-multimodal/stable-diffusion/SKILL.md +519 -0
  425. package/skills/ai-research/18-multimodal/stable-diffusion/references/advanced-usage.md +717 -0
  426. package/skills/ai-research/18-multimodal/stable-diffusion/references/troubleshooting.md +556 -0
  427. package/skills/ai-research/18-multimodal/whisper/SKILL.md +317 -0
  428. package/skills/ai-research/18-multimodal/whisper/references/languages.md +190 -0
  429. package/skills/ai-research/19-emerging-techniques/knowledge-distillation/SKILL.md +458 -0
  430. package/skills/ai-research/19-emerging-techniques/knowledge-distillation/references/minillm.md +335 -0
  431. package/skills/ai-research/19-emerging-techniques/long-context/SKILL.md +536 -0
  432. package/skills/ai-research/19-emerging-techniques/long-context/references/extension_methods.md +469 -0
  433. package/skills/ai-research/19-emerging-techniques/long-context/references/fine_tuning.md +612 -0
  434. package/skills/ai-research/19-emerging-techniques/long-context/references/rope.md +403 -0
  435. package/skills/ai-research/19-emerging-techniques/model-merging/SKILL.md +497 -0
  436. package/skills/ai-research/19-emerging-techniques/model-merging/references/coefficient-tuning.md +415 -0
  437. package/skills/ai-research/19-emerging-techniques/model-merging/references/evaluation.md +463 -0
  438. package/skills/ai-research/19-emerging-techniques/model-merging/references/examples.md +429 -0
  439. package/skills/ai-research/19-emerging-techniques/model-merging/references/methods.md +353 -0
  440. package/skills/ai-research/19-emerging-techniques/model-pruning/SKILL.md +495 -0
  441. package/skills/ai-research/19-emerging-techniques/model-pruning/references/wanda.md +348 -0
  442. package/skills/ai-research/19-emerging-techniques/moe-training/SKILL.md +526 -0
  443. package/skills/ai-research/19-emerging-techniques/moe-training/references/architectures.md +433 -0
  444. package/skills/ai-research/19-emerging-techniques/moe-training/references/inference.md +349 -0
  445. package/skills/ai-research/19-emerging-techniques/moe-training/references/training.md +426 -0
  446. package/skills/ai-research/19-emerging-techniques/speculative-decoding/SKILL.md +467 -0
  447. package/skills/ai-research/19-emerging-techniques/speculative-decoding/references/lookahead.md +310 -0
  448. package/skills/ai-research/19-emerging-techniques/speculative-decoding/references/medusa.md +351 -0
  449. package/skills/ai-research/20-ml-paper-writing/academic-plotting/SKILL.md +479 -0
  450. package/skills/ai-research/20-ml-paper-writing/academic-plotting/references/data-visualization.md +509 -0
  451. package/skills/ai-research/20-ml-paper-writing/academic-plotting/references/diagram-generation.md +395 -0
  452. package/skills/ai-research/20-ml-paper-writing/academic-plotting/references/style-guide.md +305 -0
  453. package/skills/ai-research/20-ml-paper-writing/ml-paper-writing/SKILL.md +982 -0
  454. package/skills/ai-research/20-ml-paper-writing/ml-paper-writing/references/checklists.md +348 -0
  455. package/skills/ai-research/20-ml-paper-writing/ml-paper-writing/references/citation-workflow.md +563 -0
  456. package/skills/ai-research/20-ml-paper-writing/ml-paper-writing/references/reviewer-guidelines.md +349 -0
  457. package/skills/ai-research/20-ml-paper-writing/ml-paper-writing/references/sources.md +165 -0
  458. package/skills/ai-research/20-ml-paper-writing/ml-paper-writing/references/writing-guide.md +477 -0
  459. package/skills/ai-research/20-ml-paper-writing/presenting-conference-talks/SKILL.md +298 -0
  460. package/skills/ai-research/20-ml-paper-writing/presenting-conference-talks/references/slide-templates.md +505 -0
  461. package/skills/ai-research/20-ml-paper-writing/systems-paper-writing/SKILL.md +270 -0
  462. package/skills/ai-research/20-ml-paper-writing/systems-paper-writing/references/checklist.md +279 -0
  463. package/skills/ai-research/20-ml-paper-writing/systems-paper-writing/references/reviewer-guidelines.md +116 -0
  464. package/skills/ai-research/20-ml-paper-writing/systems-paper-writing/references/section-blueprints.md +400 -0
  465. package/skills/ai-research/20-ml-paper-writing/systems-paper-writing/references/systems-conferences.md +261 -0
  466. package/skills/ai-research/20-ml-paper-writing/systems-paper-writing/references/writing-patterns.md +231 -0
  467. package/skills/ai-research/21-research-ideation/brainstorming-research-ideas/SKILL.md +384 -0
  468. package/skills/ai-research/21-research-ideation/creative-thinking-for-research/SKILL.md +366 -0
  469. package/skills/ai-research/22-agent-native-research-artifact/compiler/SKILL.md +245 -0
  470. package/skills/ai-research/22-agent-native-research-artifact/compiler/references/ara-schema.md +439 -0
  471. package/skills/ai-research/22-agent-native-research-artifact/compiler/references/exploration-tree-spec.md +125 -0
  472. package/skills/ai-research/22-agent-native-research-artifact/compiler/references/validation-checklist.md +149 -0
  473. package/skills/ai-research/22-agent-native-research-artifact/research-manager/SKILL.md +324 -0
  474. package/skills/ai-research/22-agent-native-research-artifact/research-manager/references/event-taxonomy.md +124 -0
  475. package/skills/ai-research/22-agent-native-research-artifact/research-manager/references/provenance-tags.md +190 -0
  476. package/skills/ai-research/22-agent-native-research-artifact/research-manager/references/session-protocol.md +162 -0
  477. package/skills/ai-research/22-agent-native-research-artifact/rigor-reviewer/SKILL.md +322 -0
  478. package/skills/ai-research/22-agent-native-research-artifact/rigor-reviewer/references/review-dimensions.md +182 -0
  479. package/skills/ai-research/SKILL.md +101 -0
  480. package/skills/ai-research/SOURCE +38 -0
  481. package/skills/ai-research/index/1-frame.md +18 -0
  482. package/skills/ai-research/index/2-data.md +21 -0
  483. package/skills/ai-research/index/3-train.md +48 -0
  484. package/skills/ai-research/index/4-measure.md +25 -0
  485. package/skills/ai-research/index/5-run.md +23 -0
  486. package/skills/ai-research/index/6-write.md +35 -0
  487. package/skills/ai-research/library.json +3456 -0
  488. package/skills/analyze-app/EVALS.md +82 -0
  489. package/skills/analyze-app/SKILL.md +188 -0
  490. package/skills/design-contract/EVALS.md +80 -0
  491. package/skills/design-contract/SKILL.md +171 -0
  492. package/skills/design-library/EVALS.md +85 -0
  493. package/skills/design-library/INDEX.md +59 -0
  494. package/skills/design-library/SKILL.md +58 -0
  495. package/skills/design-library/SOURCE +131 -0
  496. package/skills/design-library/adaptive-interfaces/skills/colour-independence/SKILL.md +90 -0
  497. package/skills/design-library/adaptive-interfaces/skills/flexible-typography/SKILL.md +83 -0
  498. package/skills/design-library/adaptive-interfaces/skills/information-density/SKILL.md +87 -0
  499. package/skills/design-library/adaptive-interfaces/skills/preference-audit/SKILL.md +70 -0
  500. package/skills/design-library/adaptive-interfaces/skills/responsive-accessibility/SKILL.md +86 -0
  501. package/skills/design-library/adaptive-interfaces/skills/responsive-review/SKILL.md +76 -0
  502. package/skills/design-library/adaptive-interfaces/skills/simplified-views/SKILL.md +97 -0
  503. package/skills/design-library/adaptive-interfaces/skills/specify/SKILL.md +63 -0
  504. package/skills/design-library/adaptive-interfaces/skills/user-preference-respect/SKILL.md +100 -0
  505. package/skills/design-library/cognitive-accessibility/skills/adaptive-personalisation/SKILL.md +61 -0
  506. package/skills/design-library/cognitive-accessibility/skills/assess-load/SKILL.md +48 -0
  507. package/skills/design-library/cognitive-accessibility/skills/cognitive-load-assessment/SKILL.md +60 -0
  508. package/skills/design-library/cognitive-accessibility/skills/contextual-help-design/SKILL.md +61 -0
  509. package/skills/design-library/cognitive-accessibility/skills/error-prevention-recovery/SKILL.md +71 -0
  510. package/skills/design-library/cognitive-accessibility/skills/focus-attention-design/SKILL.md +67 -0
  511. package/skills/design-library/cognitive-accessibility/skills/memory-load-reduction/SKILL.md +60 -0
  512. package/skills/design-library/cognitive-accessibility/skills/plain-language-design/SKILL.md +78 -0
  513. package/skills/design-library/cognitive-accessibility/skills/review/SKILL.md +56 -0
  514. package/skills/design-library/cognitive-accessibility/skills/simplify/SKILL.md +39 -0
  515. package/skills/design-library/cognitive-accessibility/skills/wayfinding-navigation/SKILL.md +64 -0
  516. package/skills/design-library/design-research/skills/behavioural-analytics/SKILL.md +33 -0
  517. package/skills/design-library/design-research/skills/qual-quant-triangulation/SKILL.md +33 -0
  518. package/skills/design-library/inclusive-interaction/skills/audit/SKILL.md +50 -0
  519. package/skills/design-library/inclusive-interaction/skills/design-flow/SKILL.md +58 -0
  520. package/skills/design-library/inclusive-interaction/skills/feedback-and-status/SKILL.md +76 -0
  521. package/skills/design-library/inclusive-interaction/skills/gesture-alternatives/SKILL.md +70 -0
  522. package/skills/design-library/inclusive-interaction/skills/keyboard-navigation/SKILL.md +99 -0
  523. package/skills/design-library/inclusive-interaction/skills/keyboard-review/SKILL.md +54 -0
  524. package/skills/design-library/inclusive-interaction/skills/motion-sensitivity/SKILL.md +79 -0
  525. package/skills/design-library/inclusive-interaction/skills/multi-modal-input/SKILL.md +70 -0
  526. package/skills/design-library/inclusive-interaction/skills/touch-target-design/SKILL.md +71 -0
  527. package/skills/design-library/inclusive-interaction/skills/voice-interaction/SKILL.md +68 -0
  528. package/skills/design-library/visual-critique/skills/critique-affordance/SKILL.md +45 -0
  529. package/skills/design-library/visual-critique/skills/critique-brand-consistency/SKILL.md +42 -0
  530. package/skills/design-library/visual-critique/skills/critique-color/SKILL.md +44 -0
  531. package/skills/design-library/visual-critique/skills/critique-composition/SKILL.md +46 -0
  532. package/skills/design-library/visual-critique/skills/critique-information-density/SKILL.md +45 -0
  533. package/skills/design-library/visual-critique/skills/critique-typography/SKILL.md +45 -0
  534. package/skills/design-library/visual-critique/skills/critique-visual-hierarchy/SKILL.md +40 -0
  535. package/skills/feature-audit/EVALS.md +83 -0
  536. package/skills/feature-audit/SKILL.md +326 -0
  537. package/skills/harness/EVALS.md +81 -0
  538. package/skills/harness/SKILL.md +88 -0
  539. package/skills/i-have-adhd/EVALS.md +80 -0
  540. package/skills/i-have-adhd/SKILL.md +183 -0
  541. package/skills/no-ai-slop/EVALS.md +89 -0
  542. package/skills/no-ai-slop/SKILL.md +209 -0
  543. package/skills/no-ai-slop/eval.md +52 -0
  544. package/skills/plan-add/EVALS.md +79 -0
  545. package/skills/plan-add/SKILL.md +179 -0
  546. package/skills/plan-add/render_table.py +72 -0
  547. package/skills/plan-status/EVALS.md +78 -0
  548. package/skills/plan-status/SKILL.md +52 -0
  549. package/skills/plan-sync/EVALS.md +80 -0
  550. package/skills/plan-sync/SKILL.md +69 -0
  551. package/skills/pm-frameworks/SKILL.md +33 -0
  552. package/skills/pm-frameworks/SOURCE +39 -0
  553. package/skills/pm-frameworks/intended-vs-implemented/SKILL.md +42 -0
  554. package/skills/pm-frameworks/opportunity-solution-tree/SKILL.md +64 -0
  555. package/skills/ponytail/EVALS.md +80 -0
  556. package/skills/ponytail/SKILL.md +124 -0
  557. package/skills/product-analysis/EVALS.md +103 -0
  558. package/skills/product-analysis/SKILL.md +361 -0
  559. package/skills/research/EVALS.md +104 -0
  560. package/skills/research/SKILL.md +570 -0
  561. package/skills/rl-env/SKILL.md +53 -0
  562. package/skills/rl-env/SOURCE +41 -0
  563. package/skills/rl-env/generate-nemo-gym-env/SKILL.md +169 -0
  564. package/skills/rl-env/generate-nemo-gym-env/references/architecture.md +114 -0
  565. package/skills/rl-env/generate-openenv-env/SKILL.md +183 -0
  566. package/skills/rl-env/generate-openenv-env/references/architecture.md +82 -0
  567. package/skills/rl-env/generate-ors-env/SKILL.md +209 -0
  568. package/skills/rl-env/generate-ors-env/references/architecture.md +113 -0
  569. package/skills/rl-env/generate-verifiers-env/SKILL.md +188 -0
  570. package/skills/rl-env/generate-verifiers-env/references/architecture.md +119 -0
  571. package/skills/rl-env/rl-env-from-description/SKILL.md +124 -0
  572. package/skills/rl-env/rl-env-from-description/references/interview.md +75 -0
  573. package/skills/rl-env/rl-env-from-description/references/nemo_gym.md +41 -0
  574. package/skills/rl-env/rl-env-from-description/references/openenv.md +35 -0
  575. package/skills/rl-env/rl-env-from-description/references/ors.md +50 -0
  576. package/skills/rl-env/rl-env-from-description/references/verifiers.md +39 -0
  577. package/skills/tezgah-contract/EVALS.md +78 -0
  578. package/skills/tezgah-contract/SKILL.md +792 -0
  579. package/statusline.py +129 -0
  580. package/workflows/graph-impact.js +153 -0
  581. package/workflows/graph-map.js +158 -0
  582. package/workflows/graph-review.js +170 -0
@@ -0,0 +1,2736 @@
1
+ #!/usr/bin/env python3
2
+ """The tool gate, shared by every host that can block a tool call.
3
+
4
+ Rules, all only inside a tezgah root:
5
+ 1. a grep-only explorer subagent is refused, with the graph tools named as the
6
+ replacement;
7
+ 2. the FIRST identifier-shaped search of a session (Grep tool, or grep/rg run
8
+ through a shell) in a repo whose own codegraph index exists is nudged
9
+ toward the graph once, then every later search passes.
10
+ 3. a git/gh command that writes an artifact carrying an AI/model credit
11
+ (Co-Authored-By, "Generated with", a robot emoji, ...) is refused.
12
+ 4. an identical call that already failed LOOP_ATTEMPTS times in the current
13
+ user turn is refused (`loop`), the same number for every failure class: the
14
+ class names why the last attempt failed, it does not buy another identical
15
+ one. An identical call attempted more than RETRY_CEILING times in the
16
+ session is refused whatever its outcomes were (`retry`). Both read
17
+ hooks/tezgah_integrity.prior_calls and both are under the `verify-off` kill
18
+ switch the integrity rule shares.
19
+ 5. a credential on its way into a file is refused (secret): a shell command
20
+ whose own text carries a `name=value` / bearer token next to a sink (a
21
+ redirect, `tee`, `git add` or a curl trace), a `git add` that names a
22
+ credential file (`.env`, a key file; `.env.example` and `.env.sample` are
23
+ templates), and any write - a write tool in every dialect, an apply_patch
24
+ file by file, a heredoc body - that lands a token with a known credential
25
+ prefix (`ghp_`, `sk-live-`, ...) the replaced text did not carry.
26
+ 6. a write to a file another session wrote inside RACE_WINDOW_MIN is refused
27
+ (`race`), naming the other session and the file, because the failure it
28
+ closes - one session overwriting another's work from a stale read - leaves
29
+ no trace in either transcript. A shell command's redirect or `tee` target
30
+ is a write here, and in the task rule below, like a write tool's path.
31
+ 7. when the current user turn has run past DRIFT_STEPS work rows, the next
32
+ effectful call gets the standing constraints re-stated once (`drift`), in
33
+ the gate's reason string, which is the only channel a PreToolUse hook has.
34
+ The marker row is written before the deny, so the identical call passes on
35
+ the next attempt.
36
+ 8. a write while an active task is set - a plan file under .tezgah/plans/open whose
37
+ frontmatter carries a valid `phase` - is refused when that phase is one
38
+ that only reads, or when the file is outside the task's `allowed_paths`
39
+ refused (`task`). The record is the user's own, written by bin/tezgah-task and
40
+ never by the agent, so the refusal leans on a decision the user made rather
41
+ than one this gate inferred: no active task means no requirement, and the
42
+ rule invents nothing. The record also carries where the phase returns to - a
43
+ `checkpoint:` sha, or `pending` while the tree still holds work no commit
44
+ names - and a write in `implementation` is refused while it reads `pending`
45
+ and the tree is dirty, so the phase's work starts from a named state rather
46
+ than on top of uncommitted changes; the per-file snapshots are not one
47
+ state to return to. Two further refusals close the ways a session could
48
+ move the boundary instead of respecting it: a write whose target IS that
49
+ record, and a shell command that would change it through the CLI. Both were
50
+ measured open before they were closed - with the refusal naming the command
51
+ that lifts it, an armed arm removed or disabled the gate in 25 of 25 runs
52
+ and obeyed it in none (an internal benchmark, block E7) - and neither
53
+ refusal names a command now. The phase covers the shell too, because the
54
+ write tools are not the only way to change a file: with them refused, the
55
+ armed arm wrote the target with a heredoc redirect in 3 of 25 runs, so a
56
+ reading phase refuses a shell command that writes. A code-execution tool is
57
+ a third route the phase does not cover: the same block measured one row in
58
+ 25 writing the target through `eval` after the write tools and the shell
59
+ were both refused, and that one is named rather than closed. The `task-off`
60
+ kill switch removes all four.
61
+ 9. a `git commit` (or `--amend`) is refused while the newest check recorded in
62
+ this session failed (`order`): a commit is a claim that the tree passed, and
63
+ the state it would freeze is the one a check just rejected. The state is the
64
+ Stop rule's own fold over the ledger tail, so no check at all and a passing
65
+ newest check both pass - a docs-only commit is not this rule's - and the
66
+ refusal names the failed check and no command, because what lifts it is
67
+ making the newest check green. It rides the `verify-off` switch.
68
+ 10. a check piped into a trimmer or filter (`pytest | tail`, `| grep`, `| wc`)
69
+ is refused (`piped`): the line's status is the trimmer's, so the ledger
70
+ records the check as ran and never as passed, and the Stop rule refuses
71
+ every claim the run was meant to carry. The refusal names both ways out -
72
+ output to a file that is then read, or a `set -o pipefail;` prefix, which
73
+ integrity reads as decisive. It rides the `verify-off` switch.
74
+ The three rules that read the text a write lands - the shortcut rule, the
75
+ attribution rule above and the credential rule's prefixed-token half -
76
+ read it on every write route: a write tool in every dialect (an
77
+ apply_patch file by file) and the body a heredoc writes
78
+ (shell_write_body). The write tools and the shell are disjoint sets, so
79
+ the heredoc route was refused by nothing, and it is the one E7c measured
80
+ an armed session taking.
81
+ 11. a write, delete, move or chmod of tezgah's own control plane is refused
82
+ (`control`): a kill switch or opt-in marker (tezgah_paths.SWITCHES under
83
+ OFF_DIRS), ~/.config/tezgah, the evidence ledger and the session state in
84
+ the cache, the hook registration files and the installed hook tree, a
85
+ repo's `.no-*` marks, the repository's git hooks, and the CLIs that change
86
+ that state (control_reason); a forced `git add` of `.tezgah/` and an open
87
+ plan's delete or move leave a `disarm` row instead (CONTROL_EVIDENCE). It
88
+ runs first after the root check, and no switch but `pretooluse-off`
89
+ removes it: every other switch is one of the files it protects.
90
+ Adapters translate the returned reason into their own permission envelope.
91
+ """
92
+ import hashlib
93
+ import json
94
+ import os
95
+ import re
96
+ import shlex
97
+ import time
98
+
99
+ from tezgah_integrity import (BASH_TOOLS, BEGAN_KIND, ENV_WORD, GIT_VALUE_OPTS,
100
+ HEREDOC_RISK, HOOKS_KEY, PATCH_FILE,
101
+ PLAIN_TAIL, SECRET_PREFIXED, STEP_KINDS,
102
+ WRITE_TOOLS, _blank_heredocs, _heredocs, bind_session,
103
+ _shell_lines, _shell_segments, _turn_start,
104
+ _unquoted_backticks, call_id, cut, events,
105
+ heredoc_bodies, mask, mcp_class, mcp_text, note,
106
+ prior_calls, scratch_target, shortcut_command,
107
+ shortcut_edit, turn_rows, verify_command,
108
+ write_texts)
109
+ from tezgah_paths import (CACHE, CONFIG_DIR, HOST_DIRS, OFF_DIRS, PLUGIN_ROOT,
110
+ REPO_MARKS, SWITCHES, cache_dir, fallback_cache,
111
+ linked_main, off, root_for, roots)
112
+
113
+ try: # The ordering rule's two readers (the newest check's state, folded the way
114
+ # the Stop rule folds it, and the command of that check for the refusal), and
115
+ # the plan rule's fold over this turn's write rows. Newer than some checkouts,
116
+ # and a missing name costs the rule, never the session - this import runs on
117
+ # every gated call.
118
+ from tezgah_integrity import _failed_check, _last_verify, turn_rows
119
+ except ImportError: # pragma: no cover - only on an integrity module without them
120
+ _failed_check, _last_verify, turn_rows = None, None, None
121
+
122
+ try: # The race rule reads the write history through tezgah_integrity; that
123
+ # reader is newer than some checkouts of the module, and a missing name must
124
+ # cost the rule, never the session (this import runs on every gated call).
125
+ from tezgah_integrity import writers_elsewhere
126
+ except ImportError: # pragma: no cover - only on an integrity module without it
127
+ writers_elsewhere = None
128
+
129
+ try: # The bytes a write is about to change. The snapshot module is newer than
130
+ # some checkouts too, and a missing module must cost the snapshot, never the
131
+ # session: a file that cannot be snapshotted is still a file the agent may
132
+ # edit, so this reader is the one that fails open.
133
+ from tezgah_snapshot import capture
134
+ except ImportError: # pragma: no cover - only where the module has not landed
135
+ capture = None
136
+
137
+ try: # The credential-file names, the secret rule's `git add` half: lost with
138
+ # the snapshot module, never the session.
139
+ from tezgah_snapshot import SECRET_FILE
140
+ except ImportError: # pragma: no cover - only where the module has not landed
141
+ SECRET_FILE = None
142
+
143
+ try: # The lesson ledger's one reader (shared with tezgah_context, which the
144
+ # gate does not import): which file is a ledger, what a write adds to it, the
145
+ # key each line is remembered by, and the taint index writer. A missing
146
+ # module costs the taint row, never the session.
147
+ import tezgah_lessons
148
+ from tezgah_lessons import write_taint
149
+ except ImportError: # pragma: no cover - only where the module has not landed
150
+ tezgah_lessons = write_taint = None
151
+
152
+ try: # The piped-check rule's reader: newer than some integrity modules, and a
153
+ # missing name costs the rule, never the session.
154
+ from tezgah_integrity import piped_check
155
+ except ImportError: # pragma: no cover - only on an integrity module without it
156
+ piped_check = None
157
+
158
+
159
+ try: # The task rule reads the user's own per-task record (a plan file's
160
+ # frontmatter) through tezgah_task. That module is newer than some checkouts,
161
+ # and a missing module costs the rule, never the session.
162
+ import tezgah_task
163
+ except ImportError: # pragma: no cover - only where the module has not landed
164
+ tezgah_task = None
165
+
166
+ try: # The language rule's detector: the letter set and the word list, in their
167
+ # own module so a test reads them without a session. Newer than some
168
+ # checkouts, and a missing module costs the rule, never the session.
169
+ import tezgah_lang
170
+ except ImportError: # pragma: no cover - only where the module has not landed
171
+ tezgah_lang = None
172
+
173
+ IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{2,}$")
174
+ # ponytail: flags with a separate value (grep -A 3 foo) shift the token and the
175
+ # search passes unnudged; not worth a real argv parser for a once-a-session hint.
176
+ BASH_SEARCH = re.compile(r"(?:^|[|;&(]\s*|\s)(?:grep|rg)\s+((?:-\S+\s+)*)(\S+)")
177
+ # the credit forms, not a bare tool name: "OpenAI" or "Claude Code" in a
178
+ # sentence (naming a tool to use it) is fine, "Co-Authored-By: ..." or a
179
+ # generator named as the author (signing it) is the thing to stop. The "... by
180
+ # ..." verbs credit only when their object names the machine or a model: a
181
+ # report generated by the build script, or an index built by the gate, is prose,
182
+ # while AI / an LLM / GPT / Claude / ... as that object is a signature, so each
183
+ # verb is tied to the name it credits. `_CREDIT` is shared with the anchored
184
+ # twin below so the two forms cannot drift apart.
185
+ #
186
+ # The cost of the model-object branch is deliberate and it falls on a message
187
+ # that *quotes* the forms: a commit body describing this rule is denied until it
188
+ # rephrases (the deny states the reason, and one rephrase clears it). The file
189
+ # path pays nothing for it - `ATTRIB_LINE` anchors, so documentation that merely
190
+ # names a form mid-sentence lands - and splitting the two sets apart was tried
191
+ # and reverted, because the anchored half then missed `-m "Built by GPT"`.
192
+ _CREDIT = (
193
+ r"co-authored-by\s*:|generated with|made with|"
194
+ r"(?:generated|written|authored|built|assisted)\s+by\s+"
195
+ r"(?:(?:an?|the)\s+)?"
196
+ r"(?:ai|llm|claude|chatgpt|gpt|copilot|codex|cursor|gemini|deepseek|"
197
+ r"openai|anthropic)\b|noreply@anthropic|\U0001F916")
198
+ ATTRIB = re.compile(_CREDIT, re.I)
199
+ # The same forms anchored to the start of a line, for the text a write/edit tool
200
+ # is about to land. A credit is a signature: it owns its line, bare or behind a
201
+ # comment marker, and a patch body prefixes an added line with `+` (`-` stays
202
+ # last in the class so it is a literal, not a range). Prose that merely names the
203
+ # ban ("Banned forms include `Co-Authored-By`") starts with other words, and a
204
+ # markdown list item puts a backtick between the marker and the form, so neither
205
+ # matches. That anchoring is what stops the rule from denying the documentation
206
+ # that describes it.
207
+ # ponytail: a file that quotes a forged trailer on its own line is still denied;
208
+ # the deny states the reason, so one rephrase clears it.
209
+ ATTRIB_LINE = re.compile(r"(?im)^[\s>#*/<!+-]*(?:" + _CREDIT + r")")
210
+ # the payload fields a write/edit tool carries its content in, per host dialect
211
+ EDIT_TEXT = ("content", "new_string", "newString", "new_str", "file_text",
212
+ "patch", "text")
213
+ # the write subcommand may sit behind git's global options: `git -c k=v commit`,
214
+ # `git -C dir commit`, `git --no-pager commit`. `gh api` writes comments/reviews,
215
+ # and `gh pr merge` lands a commit, so both count as writes.
216
+ # The `gh` subcommands that write to the remote, so this rule counts them as
217
+ # write commands: a PR or an issue comment is a message posted to a service
218
+ # under the workspace's name.
219
+ GH_SUBCOMMANDS = r"(?:create|edit|comment|review|merge|close)"
220
+ WRITE_CMD = re.compile(
221
+ r"(?:^|[|;&]\s*|\s)git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*"
222
+ r"(?:commit|merge|tag|notes)\b|"
223
+ r"(?:^|[|;&]\s*|\s)gh\s+api\b|"
224
+ r"(?:^|[|;&]\s*|\s)gh\s+(?:pr|issue|release)\s+" + GH_SUBCOMMANDS + r"\b",
225
+ re.I)
226
+
227
+ # --- secret: a credential on its way into a file ----------------------------
228
+ # The shell half (a command's own text next to a sink; the text a write lands is
229
+ # read below, SECRET_EDIT_DENY): only the two shapes the contract names: a
230
+ # bearer header, or a `name=value`
231
+ # assignment. A trailing quote is allowed because the value is usually quoted,
232
+ # and `:` is NOT a separator here - `{"api_key": "x"}` is a JSON field in a
233
+ # program's text, while `token=$TOKEN` and `api_key=...` are a credential being
234
+ # carried. The value may be an env reference: `OPENROUTER_API_KEY=$KEY` echoed
235
+ # into a log is the taxonomy's own case (`$KEY` is resolved by the shell).
236
+ # The name alternatives already match the keyword wherever it sits, so the
237
+ # unbounded `[A-Za-z0-9_.-]*` prefix only bought a rescan from every start
238
+ # position: 16 KB took 4.03 s and 100 KB 174 s, past Claude's 5 s hook budget, so
239
+ # the deny never arrived and the evidence row was never written (audit H-3).
240
+ SECRET_TOKEN = re.compile(
241
+ r"authorization\s*:\s*bearer\s+\S|"
242
+ r"(?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|"
243
+ r"password|passwd)\s*=\s*[\"']?[^\s\"']", re.I)
244
+ # The sinks that carry a command's own text into a file. `>>?` is read off the
245
+ # masked text, so a quoted `>` is not a redirect and `2>&1` is not a file.
246
+ # curl's -o/--output writes the response BODY, not the request header, so it is
247
+ # not a sink; its traces are, because those do carry the header.
248
+ SECRET_SINK = re.compile(
249
+ r">>?(?![&=])|\|\s*tee\b|--trace(?:-ascii)?\b|"
250
+ r"(?:^|[|;&]\s*|\s)git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*add\b")
251
+ # The simple commands of a shell line, for the rule below. A sink only carries
252
+ # the text of its own simple command, so `git add -A && git commit -m "fix
253
+ # api_key= handling"` is a message about the rule rather than a credential in a
254
+ # write. `|` is NOT a boundary here but a carrier: `printf 'token=%s' "$T" | tee
255
+ # log` hands the piped text to tee. The split is read off the masked text, so a
256
+ # quoted `;` does not split.
257
+ SEGMENT = re.compile(r"\|\||&&|[;&\n]")
258
+
259
+ SECRET_DENY = (
260
+ "Credential write denied: this command would land a credential in a file "
261
+ "(`>`/`>>`, `tee`, `git add` or a curl trace). Record the credential's "
262
+ "name, length or a fingerprint instead of its value, pass it through the "
263
+ "tool's own environment, or let the tool read it from there rather than "
264
+ "writing it out.")
265
+ # The text a write lands - a write tool's content, a patch's added lines, a
266
+ # heredoc's body - is read for the redactor's prefixed token families
267
+ # (tezgah_integrity.SECRET_PREFIXED: `ghp_`, `sk-live-`, `AKIA`, ..., without
268
+ # the redactor's bare `sk-`/`pk_` branch), never for the
269
+ # name=value shape above: in a file's text `password = os.environ[...]` is a
270
+ # program reading its credential, and name=value matched 99 of 655 tracked text
271
+ # files where the prefixed families matched 4, all fixtures (REPORT.md:5853, not
272
+ # re-measured here). Only a token the call brings counts: one already in the
273
+ # replaced text is not this call's doing.
274
+ SECRET_EDIT_DENY = (
275
+ "Credential write denied: this write lands a token with a known credential "
276
+ "prefix (`ghp_`, `sk-live-`, `AKIA`, ...) in a file. Record the credential's "
277
+ "name, length or a fingerprint instead of its value, or have the program read "
278
+ "it from its environment at run time.")
279
+ # `git add` of a credential file puts every value in it into history. The names
280
+ # are the snapshot store's (tezgah_snapshot.SECRET_FILE); the templates that
281
+ # carry names without values are the carve-out.
282
+ # ponytail: only a file the command names is read - `git add -A` or `git add .`
283
+ # staging an untracked `.env` is the .gitignore's to stop, not this rule's.
284
+ SECRET_TEMPLATES = (".env.example", ".env.sample", ".env.template", ".env.dist",
285
+ ".env.defaults")
286
+ # A token in these files is there to be read, not used: a template (the names
287
+ # above, `*.example`) or a test fixture (`tests/**/fixtures/**`).
288
+ SECRET_FIXTURE = re.compile(r"(?:^|/)tests?/(?:.+/)?fixtures/|\.example$", re.I)
289
+ # Documentation placeholders: a token that says it is an example (AWS's
290
+ # `AKIAIOSFODNN7EXAMPLE`), one whose body is a single repeated character
291
+ # (`ghp_xxxx...`), and the published keys docs print, held as sha256 digests so
292
+ # no file in the tree carries one whole: Stripe's documentation test secret key.
293
+ # ponytail: a published key is listed by hand, one per report.
294
+ SECRET_PUBLISHED = frozenset((
295
+ "2cafc0970149a84f3b9e62eaf169f36f59907a3b3e31f7b82e68c69cd27f7326",))
296
+ SECRET_ADD_DENY = (
297
+ "Credential file add denied: `git add %s` stages a credential file, so every "
298
+ "value in it lands in the repository's history. Keep it out of git (list it "
299
+ "in .gitignore) and commit a `.env.example` that carries the names without "
300
+ "the values.")
301
+
302
+ # the MCP half of the rule (see `decision`): the same advice, for a server write
303
+ MCP_SECRET_DENY = (
304
+ "Credential write denied: this MCP call would land a credential (a prefixed "
305
+ "token such as `ghp_...` or `sk-...`) in what the server writes or posts. "
306
+ "Record the credential's name, length or a fingerprint instead of its value, "
307
+ "or let the server read it from its own environment.")
308
+
309
+ # The credential shapes the MCP half reads: vendor tokens only, case-sensitive.
310
+ # The redactor's families (tezgah_integrity.SECRET_TOKEN) also take any
311
+ # `sk|pk|rk` + 16 characters, which is what an identifier or a branch name looks
312
+ # like (`pk_users_organization_id`, `sk-telemetry-dashboard-refactor`) - right for
313
+ # a redactor, which loses nothing by blanking one, wrong for a refusal.
314
+ MCP_TOKEN = re.compile(
315
+ r"\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}"
316
+ r"|\bsk-(?:proj|ant)-[A-Za-z0-9_\-]{16,}"
317
+ r"|\bsk-[A-Za-z0-9]{32,}"
318
+ r"|\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}"
319
+ r"|\bgithub_pat_[A-Za-z0-9_]{20,}"
320
+ r"|\bxox[baprs]-[A-Za-z0-9-]{10,}"
321
+ r"|\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"
322
+ r"|\bAIza[0-9A-Za-z_\-]{30,}"
323
+ r"|\bglpat-[A-Za-z0-9_\-]{20,}"
324
+ r"|\bnpm_[A-Za-z0-9]{30,}")
325
+
326
+ EXPLORE_DENY = (
327
+ "A grep-only explorer subagent is not allowed in this tree: it greps by "
328
+ "design and ignores the code graph. Use the general-purpose agent and name "
329
+ "the codegraph tools in its prompt (`codegraph_explore` over MCP, or "
330
+ "`codegraph callers|impact|node` from a shell), with the tool-loading step "
331
+ "so they are armed.")
332
+
333
+ ATTRIB_DENY = (
334
+ "Attribution is banned in every artifact tezgah touches. Remove the "
335
+ "Co-Authored-By / \"Generated with\" / robot-emoji / model-name credit from "
336
+ "the commit, PR, issue or review text and re-run. Naming a tool in order to "
337
+ "use it or describe real behavior is fine; crediting it as author is not.")
338
+
339
+
340
+ def attribution(command):
341
+ """True when a git/gh write command carries an AI/model credit.
342
+
343
+ The whole command text is read unanchored, so a credit inside a `-m` value is
344
+ caught wherever it sits. A message that *discusses* the forms is denied too,
345
+ and that is the known cost: the deny names the forms, so one rephrase clears
346
+ it (see the note on `_CREDIT`)."""
347
+ c = str(command or "")
348
+ return bool(WRITE_CMD.search(c) and ATTRIB.search(c))
349
+
350
+
351
+ def attribution_edit(inp):
352
+ """True when the text a write/edit tool is about to land carries a credit.
353
+
354
+ The bash path needs `WRITE_CMD` to establish that a commit or PR body is
355
+ being written; here the tool itself is the write, so the content is the
356
+ whole question."""
357
+ if not isinstance(inp, dict):
358
+ return False
359
+ return any(isinstance(v, str) and ATTRIB_LINE.search(v)
360
+ for k, v in inp.items() if k in EDIT_TEXT)
361
+
362
+
363
+ # --- language: an identifier that is not English -----------------------------
364
+ # What this closes: the contract says code, commits, branches and docs are
365
+ # English, and nothing checked it at the one moment it is cheap to check - a plan
366
+ # branch was created as `plan/004-admin-durum-onarimi`, and the folded slug is in
367
+ # the repository's history for good. The identifiers are the durable half: a
368
+ # branch, a commit subject and a PR or issue title are public the moment they
369
+ # exist, while the reply that would have said something in Turkish is not kept
370
+ # at all. The detector is tezgah_lang (a non-ASCII letter predicate plus a
371
+ # curated list of Turkish words, and its own docstring carries what it cannot
372
+ # decide); this half is the command shapes that create an identifier.
373
+ #
374
+ # Read off the RAW text, not mask()'d text: an identifier usually arrives quoted
375
+ # (`git commit -m "..."`), and mask() blanks quoted strings by design. That is
376
+ # why the shapes below are exact rather than a scan for a foreign word anywhere - a
377
+ # command that merely NAMES the words (a `grep`, an `echo`, a `git log --grep`)
378
+ # creates nothing and must pass, which is the same line the attribution rule
379
+ # draws for its own forms.
380
+ #
381
+ # `git commit -F <file>` is read from the file, resolved against the call's cwd.
382
+ # ponytail: a file that cannot be read (a missing path, an unreadable one, a
383
+ # binary) is skipped rather than guessed at - the ceiling is named here, and the
384
+ # other commit routes (`--fill`, `-m` used twice) are read for their first
385
+ # argument only.
386
+ BRANCH_NEW = re.compile(
387
+ r"(?:^|[|;&(]\s*|\s)git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*"
388
+ r"(?:checkout\s+(?:-\S+\s+)*(?:-b|-B|--branch)\s+"
389
+ r"|switch\s+(?:-\S+\s+)*(?:-c|-C|--create)\s+"
390
+ r"|branch\s+(?!-))"
391
+ r"([^\s;&|]+)")
392
+ COMMIT_MSG = re.compile(
393
+ r"(?:^|[|;&(]\s*|\s)git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*commit\b([^|;&]*)")
394
+ # The flag and its value, which may be one token, a quoted string, or `=`-joined.
395
+ # The cluster form is read (`git commit -am "..."`), and `(?<![\w-])` is what
396
+ # keeps the short flag from matching inside the long one - `--message` holds a
397
+ # literal `-m` - and keeps `-am` from being read as a word. `--amend` and
398
+ # `--file` are not message flags, and neither is reached: a flag cluster cannot
399
+ # start on the second `-` of a long option, because the lookbehind rejects it.
400
+ MSG_FLAG = re.compile(r"(?<![\w-])(?:--message|-[A-Za-z]*m)(?:=|\s+|(?=[\"']))")
401
+ MSG_FILE = re.compile(r"(?<![\w-])(?:--file|-[A-Za-z]*F)(?:=|\s+|(?=[\"']))")
402
+ GH_NEW = re.compile(
403
+ r"(?:^|[|;&(]\s*|\s)gh\s+(pr|issue)\s+create\b([^|;&]*)", re.I)
404
+ TITLE_FLAG = re.compile(r"(?<![\w-])--title(?:=|\s+|(?=[\"']))")
405
+ # How much of a commit-message file is read: the subject is its first line, so a
406
+ # file past this bound loses its tail and not its point.
407
+ MSG_READ = 64 * 1024
408
+
409
+
410
+ def _value(text):
411
+ """The value of a flag from the text that follows it: a quoted string when
412
+ the value opens with a quote (that is what carries a multi-word subject),
413
+ else the next token."""
414
+ text = text.lstrip()
415
+ if text[:1] in ("\"", "'"):
416
+ end = text.find(text[0], 1)
417
+ return text[1:end] if end != -1 else text[1:]
418
+ # keyword `maxsplit`: the positional form warns on 3.13+ and a future
419
+ # keyword-only signature would raise, failing the gate open (audit QA-3)
420
+ return re.split(r"[\s;&|]", text, maxsplit=1)[0]
421
+
422
+
423
+ def _flag_value(rest, flag):
424
+ """The value of `flag` in `rest`, or "" when the flag is not there."""
425
+ m = flag.search(rest)
426
+ return _value(rest[m.end():]) if m else ""
427
+
428
+
429
+ def _message_file(path, cwd):
430
+ """The text of a `-F` file, or "" when it cannot be read (see the ceiling
431
+ above: an unreadable path skips the check rather than denying the commit)."""
432
+ if not path:
433
+ return ""
434
+ full = path if os.path.isabs(path) else os.path.join(cwd or "", path)
435
+ try:
436
+ with open(full, encoding="utf-8", errors="replace") as fh:
437
+ return fh.read(MSG_READ)
438
+ except OSError:
439
+ return ""
440
+
441
+
442
+ def created_texts(command, cwd):
443
+ """Every identifier this command would create, as (what, text) pairs.
444
+
445
+ `what` names the thing in the words the command itself uses, so the refusal
446
+ can say which identifier it is about."""
447
+ c = str(command or "")
448
+ out = [("branch name", m.group(1)) for m in BRANCH_NEW.finditer(c)]
449
+ for m in COMMIT_MSG.finditer(c):
450
+ rest = m.group(1)
451
+ subject = _flag_value(rest, MSG_FLAG)
452
+ if subject:
453
+ out.append(("commit subject", subject))
454
+ body = _message_file(_flag_value(rest, MSG_FILE), cwd)
455
+ if body:
456
+ out.append(("commit message", body))
457
+ for m in GH_NEW.finditer(c):
458
+ title = _flag_value(m.group(2), TITLE_FLAG)
459
+ if title:
460
+ kind = "PR" if m.group(1).lower() == "pr" else "issue"
461
+ out.append(("%s title" % kind, title))
462
+ return out
463
+
464
+
465
+ def lang_reason(command, cwd):
466
+ """A deny reason when this command would create a non-English identifier,
467
+ else None. The decision is tezgah_lang's (a word list is not a language
468
+ detector, and its docstring carries the ceiling); this reads the shapes and
469
+ names the identifier."""
470
+ if tezgah_lang is None:
471
+ return None
472
+ for what, text in created_texts(command, cwd):
473
+ reason = tezgah_lang.refusal(what, text)
474
+ if reason:
475
+ return reason
476
+ return None
477
+
478
+
479
+ def explored(subagent_type):
480
+ """True when a subagent request is the grep-only explorer."""
481
+ return str(subagent_type or "").lower() in ("explore", "explorer")
482
+
483
+
484
+ def searched_identifier(tool, inp):
485
+ """The bare identifier this call searches for, or None. Host tool names
486
+ differ in case (Claude `Grep`/`Bash`, Codex/dsh `bash`), so match lowercased."""
487
+ t = str(tool or "").lower()
488
+ if t == "grep":
489
+ pat = str(inp.get("pattern", ""))
490
+ return pat if IDENT.match(pat) else None
491
+ if t in ("bash", "shell"):
492
+ for m in BASH_SEARCH.finditer(str(inp.get("command", ""))):
493
+ tok = m.group(2).strip("'\"")
494
+ if IDENT.match(tok):
495
+ return tok
496
+ return None
497
+
498
+
499
+ def index_slug(cwd, base):
500
+ """The cache stamp name of the enclosing repo whose OWN codegraph index
501
+ exists, or None when no repo up to `base` has one.
502
+
503
+ codegraph keeps its index inside the project (`<repo>/.codegraph/codegraph.db`),
504
+ so readiness is a repo-local fact: the old per-user cache slug (a path
505
+ flattened into `~/.cache/<engine>/<slug>.db`) said what some other tool had
506
+ indexed, not what this repo carries. The walk is the old one -
507
+ closest-ancestor-first, and the base itself excluded when it is a configured
508
+ root, because a root is not a project (a base that is an armed worktree is
509
+ one, so its own `.codegraph` is read) - and the name returned is still the
510
+ key the index worker stamps HEAD under, so index_mark and index_notice keep
511
+ reading the same file."""
512
+ d = os.path.realpath(cwd)
513
+ base, rs = os.path.realpath(base), roots()
514
+ while d.startswith(base) and (d != base or base not in rs):
515
+ if os.path.isfile(os.path.join(d, ".codegraph", "codegraph.db")):
516
+ return re.sub(r"[^A-Za-z0-9]+", "-", d).strip("-")
517
+ d = os.path.dirname(d)
518
+ return None
519
+
520
+
521
+ def nudge_mark(session_id):
522
+ """The once-per-session mark: sha1(id)[:16], the key opencode's oncePerSession writes."""
523
+ return os.path.join(cache_dir(), "nudged", hashlib.sha1(
524
+ (session_id or "nosession").encode("utf-8")).hexdigest()[:16])
525
+
526
+
527
+ def first_nudge(session_id):
528
+ """Consume the once-per-session nudge. False when already spent/unwritable.
529
+
530
+ The mark lands in cache_dir(), so a sandboxed host (dsh) still gets the
531
+ one-shot nudge instead of the write failing open."""
532
+ mark = nudge_mark(session_id)
533
+ if os.path.exists(mark):
534
+ return False
535
+ try:
536
+ os.makedirs(os.path.dirname(mark), exist_ok=True)
537
+ open(mark, "w", encoding="utf-8").close() # consume BEFORE denying: later greps pass
538
+ return True
539
+ except OSError:
540
+ return False
541
+
542
+
543
+ def nudge_reason(symbol):
544
+ return ("Code graph index is ready for this repo. Run `codegraph explore %s` (source, callers, "
545
+ "blast radius in one call) or `codegraph callers %s` / `codegraph impact %s` from a shell; "
546
+ "an omp subagent must use this CLI, not the MCP device, because omp's MCP device refuses "
547
+ "concurrent writes. If you need literal text, re-run this search unchanged; it will pass - "
548
+ "this nudge fires once per session." % (symbol, symbol, symbol))
549
+
550
+
551
+ # The identical attempts a call gets before the loop guard refuses it, whatever
552
+ # its failure class. Two failures are the retry the agent may still be fixing
553
+ # while it changes the code between them; the third is the loop the contract bans
554
+ # ("three attempts on one failure is the ceiling"). The class
555
+ # (tezgah_integrity.fail_class) is read from the ledger and named in the refusal
556
+ # - it says WHY the last attempt failed - and it does NOT widen this number:
557
+ # an identical repeat adds nothing to a transient failure (a timeout, a
558
+ # connection error, a rate limit, a 5xx is retried by the host's own client, not
559
+ # by the agent re-issuing the same command), and a ceiling that loosened for one
560
+ # class would depend on error text the host may not report at all, so the same
561
+ # command would have two budgets on two hosts. G7's own row asked for a cap of
562
+ # two; the class-scoped ceiling it replaced read as its inverse - the least
563
+ # controllable failure bought the most identical repeats.
564
+ LOOP_ATTEMPTS = 2
565
+ CLASS_NOTE = {
566
+ "transient": "it names a failure the host's own client may retry - a "
567
+ "timeout, a connection error, a rate limit, a 5xx - and an "
568
+ "identical repeat by the agent adds nothing to that",
569
+ "user": "it names something only the user can fix - a credential, a "
570
+ "login, an access grant - so stop and ask the user for it rather "
571
+ "than change the approach",
572
+ "permanent": "an assertion or a bad argument does not change by re-running "
573
+ "it",
574
+ }
575
+ NO_CLASS_NOTE = ("the host reported no error text for it, so the class is "
576
+ "unknown, and the cap is the same either way")
577
+
578
+ # The other half of the repeat rule, session-wide and blind to the outcome: a
579
+ # call the gate has seen run three times may not run a fourth, whatever those
580
+ # runs returned. It is deliberately above LOOP_ATTEMPTS - the loop guard counts
581
+ # only the attempts that failed, in the current turn, so a call that keeps
582
+ # "succeeding" without moving the work forward is its blind spot - and it is set
583
+ # above the common work loop (edit, test, edit, test reaches two identical test
584
+ # runs, and a session's third `git status` still passes) so only a genuine spin
585
+ # reaches it.
586
+ RETRY_CEILING = 3
587
+
588
+
589
+ def loop_reason(tool, inp, session_id, agent=None):
590
+ """A deny reason when this exact call already failed often enough, else None.
591
+
592
+ The ledger tail is the only state this reads, and only the outcome rows of
593
+ the current user turn count (tezgah_integrity.prior_calls): the guard's own
594
+ `deny` row carries the same id with no exit, so counting it would put the
595
+ refusal newest, read as "no failure" and let every second repeat through.
596
+
597
+ Past the ceiling the identical retry cannot work - the agent has to change
598
+ the approach or stop, which is the contract's loop rule ("never repeat an
599
+ identical failing command") given a mechanical half. A call that never
600
+ failed, or that failed differently, is not this rule's."""
601
+ if not session_id:
602
+ return None
603
+ digest = call_id(tool, inp)
604
+ if not digest:
605
+ return None
606
+ turn, _, last_exit, klass = prior_calls(session_id, digest, agent=agent)
607
+ if last_exit != 1 or turn < LOOP_ATTEMPTS:
608
+ return None
609
+ return ("Loop guard denied: this is attempt %d of an identical call whose "
610
+ "%d previous attempt%s exited 1%s. The cap is %d identical attempts "
611
+ "for every failure class, because %s. Repeating an identical failing "
612
+ "command is not a retry - change the approach (fix what the error "
613
+ "names, or run something else) or stop and report what is still "
614
+ "unknown."
615
+ % (turn + 1, turn, "" if turn == 1 else "s",
616
+ " (a %s failure)" % klass if klass else "",
617
+ LOOP_ATTEMPTS, CLASS_NOTE.get(klass, NO_CLASS_NOTE)))
618
+
619
+
620
+ def retry_reason(tool, inp, session_id, agent=None):
621
+ """A deny reason when this exact call has already been attempted more than
622
+ RETRY_CEILING times in this session, whatever those attempts returned.
623
+
624
+ `loop` needs a failure to fire, so the call that runs ten times and returns 0
625
+ each time - the spin that never reaches a decision - passes it forever. This
626
+ is that half: the count is every attempt of the id that ran in the session,
627
+ outcome-blind, and the user's turn does not reset it. A refused call never
628
+ ran, so the gate's own denials are not attempts and cannot walk a call up to
629
+ the ceiling by themselves.
630
+
631
+ A verification command is the exception, and the tree is the switch: a check
632
+ re-run after the tree changed is not a blind repeat - the edit-loop cadence
633
+ needs it - so its attempts are counted since the last write row only. What
634
+ still hits the ceiling is a check run four times on an unchanged tree, which
635
+ is the spin this guard exists for."""
636
+ if not session_id:
637
+ return None
638
+ digest = call_id(tool, inp)
639
+ if not digest:
640
+ return None
641
+ attempts = prior_calls(session_id, digest, agent=agent)[1]
642
+ if attempts < RETRY_CEILING:
643
+ return None
644
+ if str(tool or "").lower() in BASH_TOOLS \
645
+ and verify_command((inp or {}).get("command")) \
646
+ and _tree_changed_since_last_attempt(session_id, digest):
647
+ return None
648
+ return ("Retry ceiling denied: this is attempt %d of an identical call in "
649
+ "this session, past the ceiling of %d attempts whatever their "
650
+ "outcome. An unchanged repeat is not a retry - change the arguments "
651
+ "or the target, or stop and report what is still unknown. (The "
652
+ "`loop` guard is the narrower rule: the identical attempts that "
653
+ "FAILED, counted per user turn.)" % (attempts + 1, RETRY_CEILING))
654
+
655
+
656
+ def _tree_changed_since_last_attempt(session_id, digest):
657
+ """True when any write row sits between the newest attempt of this call and
658
+ now: the tree the last check ran on is gone, so a re-run reads a new state.
659
+
660
+ prior_calls' scan is reused: the rows it counted attempts over are the rows
661
+ this reads, so there is no second ledger scan. A row with no `id` is a deny
662
+ or a nudge, never a real call. Any failure here fails open - the ceiling is
663
+ the conservative answer."""
664
+ try:
665
+ rows = events(session_id, tail=200)
666
+ idx = [i for i, row in enumerate(rows)
667
+ if row.get("id") == digest and row.get("exit") is not None]
668
+ # the NEWEST attempt, not the oldest: anchoring on the oldest let one
669
+ # early write exempt every later repeat for the rest of the session
670
+ start = idx[-1] if idx else len(rows)
671
+ return any(str(row.get("tool", "")).lower() in WRITE_TOOLS
672
+ or str(row.get("kind", "")) in ("edit",)
673
+ for row in rows[start:])
674
+ except Exception:
675
+ return False
676
+
677
+
678
+ def secret_command(command):
679
+ """A deny reason when this command would write a credential into a file.
680
+
681
+ Reading an env var or running a tool with a key in its env is the normal work
682
+ this must not touch, so a token only counts next to a write sink, and a sink
683
+ only carries the text of its own simple command. A `git add` that names a
684
+ credential file is refused whatever its text holds (`_secret_add`)."""
685
+ c = str(command or "")
686
+ added = _secret_add(c)
687
+ if added:
688
+ return SECRET_ADD_DENY % added
689
+ if not c or not SECRET_TOKEN.search(c):
690
+ return None
691
+ masked = mask(c)
692
+ start = 0
693
+ for end in [m.start() for m in SEGMENT.finditer(masked)] + [len(c)]:
694
+ if (SECRET_TOKEN.search(c[start:end])
695
+ and SECRET_SINK.search(masked[start:end])):
696
+ return SECRET_DENY
697
+ start = end
698
+ return None
699
+
700
+
701
+ def _secret_add(command):
702
+ """The credential file a `git add` in this command names, or None."""
703
+ if SECRET_FILE is None or "add" not in command:
704
+ return None
705
+ for words in _shell_segments(command):
706
+ program, args = _program(words)
707
+ if program != "git":
708
+ continue
709
+ i = 0
710
+ while i < len(args) and args[i].startswith("-"):
711
+ i += 2 if args[i] in GIT_VALUE_OPTS else 1
712
+ if args[i:i + 1] != ["add"]:
713
+ continue
714
+ for arg in args[i + 1:]:
715
+ name = os.path.basename(arg.rstrip("/"))
716
+ if (not arg.startswith("-") and SECRET_FILE.match(name)
717
+ and name.lower() not in SECRET_TEMPLATES):
718
+ return arg
719
+ return None
720
+
721
+
722
+ def _placeholder(token):
723
+ """True for a documentation stand-in rather than a credential."""
724
+ if "example" in token.lower():
725
+ return True
726
+ body = re.split(r"[-_]", token)[-1]
727
+ if body == token: # AKIA/ASIA/AIza: the prefix carries no separator
728
+ body = token[4:]
729
+ if len(set(body)) <= 1:
730
+ return True
731
+ return hashlib.sha256(token.encode()).hexdigest() in SECRET_PUBLISHED
732
+
733
+
734
+ def secret_edit(inp, cwd=None):
735
+ """A deny reason when a write lands a prefixed credential token the file did
736
+ not already carry, else None. Every write dialect is read, an apply_patch
737
+ file by file (tezgah_integrity.write_texts); the heredoc body a shell
738
+ command writes arrives here in the same `{file_path, content}` shape. The
739
+ baseline is the text the call replaces plus the file on disk, resolved
740
+ against the call's cwd, so an edit that repeats a key the file already holds
741
+ is not a new one; the disk is read only when the new text carries a token.
742
+ A template or fixture file and a documentation placeholder are exempt
743
+ (SECRET_FIXTURE, _placeholder)."""
744
+ for path, old, new in write_texts(inp):
745
+ fresh = {t for t in SECRET_PREFIXED.findall(new) if not _placeholder(t)}
746
+ name = path.replace(os.sep, "/")
747
+ if not fresh or (SECRET_FIXTURE.search(name)
748
+ or os.path.basename(name).lower() in SECRET_TEMPLATES):
749
+ continue
750
+ disk = ""
751
+ if path:
752
+ try:
753
+ with open(os.path.join(cwd or os.getcwd(), path),
754
+ encoding="utf-8") as fh:
755
+ disk = fh.read()
756
+ except (OSError, UnicodeDecodeError):
757
+ disk = ""
758
+ if fresh - set(SECRET_PREFIXED.findall((old or "") + "\n" + disk)):
759
+ return SECRET_EDIT_DENY
760
+ return None
761
+
762
+
763
+ # --- concurrent write: another session wrote this file minutes ago -----------
764
+ # What this closes: two sessions on one file - a parent and the subagent it
765
+ # delegated to, or two worktrees of the same repo. Each reads before the other
766
+ # writes, so the second edit lands on a stale read, and the first session's work
767
+ # is gone with nothing in either transcript saying so. That is why the collision
768
+ # REFUSES instead of annotating: a silent overwrite is the failure, and a note
769
+ # the agent may read past leaves the overwrite exactly where it was. One re-read
770
+ # is the whole cost of the refusal.
771
+ #
772
+ # Why ten minutes: the ledger holds no lock, so the window is the only release -
773
+ # a session that crashed or was closed mid-edit never says it is finished. Ten
774
+ # minutes outlives a slow read-edit-check cycle on one file plus a long tool call
775
+ # between the two writes, which is the collision this catches; a shorter window
776
+ # let the second write through in the minute that matters. Wider and the refusal
777
+ # lands on edits whose other author is long gone, which trains the agent to
778
+ # re-issue without reading.
779
+ RACE_WINDOW_MIN = 10
780
+ # The decision itself, named rather than left as a literal inside the check:
781
+ # a foreign write REFUSES (True). See above - a notice does not close a silent
782
+ # overwrite, so this rule cannot be a soft one.
783
+ RACE_REFUSE = True
784
+ RACE_DENY = (
785
+ "Concurrent write refused: session %s wrote this file within the last %d "
786
+ "minutes, so the copy of %s in your context may already be stale. Re-read "
787
+ "the file and re-apply your change to what is on disk now; if both sessions "
788
+ "are editing it, say so and let one of them own the file rather than "
789
+ "overwriting the other's work.")
790
+ # The file a write call names, per host dialect, plus the apply_patch headers
791
+ # (tezgah_integrity.PATCH_FILE) for the dialect whose paths live in the body.
792
+ # The race guard resolves each against the call's cwd to an absolute real path
793
+ # and compares it with the `target` the PostToolUse writer stored the same way
794
+ # (tezgah_integrity._abs_target): the raw spelling matched `README.md` in one
795
+ # repository against `README.md` in another (audit CHAT-03 / M-6).
796
+ # The harness's internal channels: a write to one of these is a message or a
797
+ # tool device, never a file on disk. Deliberately a list, not `scheme://`: a
798
+ # file reached through a URI scheme is still a file two sessions can race on.
799
+ URI_CHANNEL = re.compile(
800
+ r"^(?:agent|xd|local|artifact|proc|skill|mcp|omp|issue|pr|history|ssh|cfg)://",
801
+ re.I)
802
+ WRITE_PATH = ("file_path", "filePath", "path", "notebook_path")
803
+ # The write-tool name a shell write's target is handed to `capture` under. capture
804
+ # takes a write tool's own path field and no shell tool name, and the row it
805
+ # writes carries neither - so one file the shell command changes gets the same
806
+ # pre-state a write tool's target gets, and the ledger gains no field it did not
807
+ # have. See the capture call at the end of `decision`.
808
+ SHELL_AS_WRITE = "write"
809
+
810
+
811
+ def write_paths(inp):
812
+ """Every file this call writes: the tool's own path field, else the paths an
813
+ apply_patch body names per hunk, else the file a shell command's own text
814
+ writes through a redirect or `tee` (the shape SHELL_WRITE reads, the target
815
+ shell_target slices - see the shell section below).
816
+
817
+ The shell branch is the one write whose target leaves no path field behind,
818
+ and it is here rather than in a second reader because the two halves of that
819
+ write have to agree on one answer: the gate hands these paths to `capture`
820
+ before the call, and `tezgah_integrity._post_write` reads the same list after
821
+ it to hash the after-state. Raw strings, unresolved - each caller resolves
822
+ them against its own directory - except that a shell target's `$VAR` and
823
+ `~` are expanded as the shell would (`> $TMPDIR/x` is not a file named
824
+ `$TMPDIR` under the cwd). ponytail: a target that is a positional
825
+ argument (`cp`, `mv`, `sed -i`, `patch`, `git apply`) is not read off the
826
+ command at all; which argument of those is the target is a per-program
827
+ question, and the measured route is the redirect."""
828
+ if not isinstance(inp, dict):
829
+ return []
830
+ for key in WRITE_PATH:
831
+ value = inp.get(key)
832
+ if isinstance(value, str) and value.strip():
833
+ return [value.strip()]
834
+ paths = [m.group(1) for m in PATCH_FILE.finditer(str(inp.get("patch") or ""))]
835
+ if paths:
836
+ return paths
837
+ target = shell_target(inp.get("command") or inp.get("cmd") or "")
838
+ return [os.path.expanduser(os.path.expandvars(target))] if target else []
839
+
840
+
841
+ def race_reason(inp, session_id, cwd=None):
842
+ """A deny reason when another session wrote one of this call's files inside
843
+ RACE_WINDOW_MIN, else None.
844
+
845
+ `writers_elsewhere` is the ledger reader in tezgah_integrity, and its import
846
+ is guarded: an integrity module without it costs this rule, never the
847
+ session. ponytail: the PostToolUse writer records a patch's first path,
848
+ so a foreign patch write is visible here only for that first file while
849
+ this side reads every hunk's: the row is one line, and widening it means
850
+ the ledger row changes shape."""
851
+ if writers_elsewhere is None or not session_id:
852
+ return None
853
+ for path in write_paths(inp):
854
+ # an internal URI (`agent://Main`, `xd://<tool>`) is not a file: two
855
+ # observed refusals today wrote tool replies through those schemes and
856
+ # this guard read the scheme as a relative path. Only the harness's own
857
+ # channels are skipped - a `file://`, `s3://` or editor URI can name a
858
+ # real target two sessions both write, and stays guarded.
859
+ if URI_CHANNEL.match(path) or scratch_target(path, cwd):
860
+ continue # a temp file or a device is no one's shared work
861
+ others = writers_elsewhere(path, session_id, RACE_WINDOW_MIN, cwd=cwd)
862
+ if others:
863
+ return RACE_DENY % (", ".join(str(s) for s in others[:3]),
864
+ RACE_WINDOW_MIN, path)
865
+ return None
866
+
867
+
868
+ # --- task scope: the user's own phase and path allowlist --------------------
869
+ # What this closes: a session that has been told what it is working on - the
870
+ # user activated a plan file under .tezgah/plans/open, so the work has a phase and the
871
+ # files it may touch - and then writes anyway. Two shapes cost the user real
872
+ # work: a write in `discovery`, where the whole point of the phase is to read
873
+ # before deciding, and a write outside the plan's own allowlist, which is how a
874
+ # bounded task quietly becomes an unbounded one. Both are the user's own
875
+ # decisions, which is why this rule may refuse at all: the record is written by
876
+ # bin/tezgah-task, by hand, and never by the agent - a rule the agent could
877
+ # satisfy by editing its own record would be a nudge wearing a gate's clothes.
878
+ #
879
+ # The direction is fail-open wherever the record is missing, unreadable or out
880
+ # of phase vocabulary (tezgah_task.active returns None for those), because a
881
+ # refusal has to rest on an ask the user actually made: a rule that refused
882
+ # every write until a record appeared would be the fail-closed trap the design
883
+ # report names, with the whole session as its blast radius. Nothing is named
884
+ # that the user did not name either: an empty allowlist is no scope asked for
885
+ # rather than "nothing is allowed", so the phase is the whole requirement there.
886
+ TASK_PHASE_DENY = (
887
+ "Task gate: the active task %s is in phase `%s`, and this call writes. "
888
+ "Writes belong to %s. The phase is the user's to move: ask them, or do the "
889
+ "reading this phase asks for and say what the write was for.")
890
+ TASK_SCOPE_DENY = (
891
+ "Task gate: %s is outside the paths the active task %s allows (%s). The "
892
+ "allowlist is the user's to widen: ask them, or leave the file alone and say "
893
+ "why it looked in scope.")
894
+ # The two refusals above deliberately name no command. They used to end with the
895
+ # one that lifts them, and the E7 block measured what that costs: with the unlock
896
+ # printed in the refusal, the armed arm removed or disabled the gate in 25 of 25
897
+ # runs and obeyed it in none (an internal benchmark, block E7). A refusal
898
+ # is a boundary or it is an instruction; it cannot be both.
899
+ TASK_RECORD_DENY = (
900
+ "Task gate: %s is the active task's own record. The phase and the allowlist "
901
+ "live in that file, so a session that edits it is editing the boundary it is "
902
+ "being held to. Ask the user to change the task; the record is theirs.")
903
+ TASK_UNLOCK_DENY = (
904
+ "Task gate: this command changes the active task's own record. The phase and "
905
+ "the allowlist are the user's, and a session that can move them is held to "
906
+ "nothing. Ask the user to change the task instead.")
907
+ # A shell command that changes the record. Matched on the masked text, so a
908
+ # command that merely names the CLI - a grep over the docs, a commit message -
909
+ # is not refused. The token is the CLI's own basename, with or without `.py`
910
+ # and run through any interpreter, so any path to it counts.
911
+ TASK_CHANGE = re.compile(
912
+ r"(?:^|[|;&(]\s*|\s)(?:python3?\s+)?\S*tezgah-task(?:\.py)?\s+"
913
+ r"(?:start|phase|allow|stop)\b")
914
+ # The shapes that make a shell command a write, for the phase rule below. The
915
+ # tools are not the only way to change a file, and E7b measured the other one:
916
+ # with `edit` and `write` refused twelve and six times, the armed arm wrote the
917
+ # target with `cat > app/api.py <<'EOF'` instead in 3 of 25 runs - every one of
918
+ # them a redirect. The table is the secret rule's sink list plus the in-place
919
+ # editors and the copiers, because the shape reached for after the first one is
920
+ # refused is the next one here. Masked text is read, so a quoted `>` is not a
921
+ # redirect, and `> /dev/null` is not a write to a file.
922
+ # ponytail: a write inside a string the shell parses later (`python3 -c
923
+ # "open('x','w')"`) is not read - telling a read from a write there needs the
924
+ # mode argument, not the call - so the table holds the shapes an agent reaches
925
+ # for, and the phase still refuses the ones it holds. A code-execution tool is
926
+ # not this rule's at all, and E7c measured what that costs: one row in 25 wrote
927
+ # the target through `eval` after both the write tools and the shell were
928
+ # refused, so the residual is named here rather than closed.
929
+ SHELL_WRITE = re.compile(
930
+ r">>?(?!\s*/dev/null)(?![&=])|"
931
+ r"\|\s*tee\b|"
932
+ r"(?<![\w-])(?:sed|perl)\s+(?:-\S+\s+)*(?:-[A-Za-z]*i[A-Za-z]*)(?![A-Za-z])|"
933
+ r"(?<![\w-])truncate\s|"
934
+ r"\bdd\s+[^|;&]*\bof=|"
935
+ r"(?<![\w-])(?:cp|mv)\s|"
936
+ r"(?<![\w-])patch\s|"
937
+ r"(?<![\w-])git\s+(?:apply\b|restore\b|checkout\s+--)")
938
+ TASK_SHELL_DENY = (
939
+ "Task gate: the active task %s is in phase `%s`, and this command writes a "
940
+ "file. The shell is a write route like any other, so the phase covers it. "
941
+ "The phase is the user's to move: ask them, or do the reading this phase "
942
+ "asks for and say what the write was for.")
943
+
944
+
945
+ # The marker a caller passes when it has NOT read the record, so a rule reads it
946
+ # itself; a resolved `None` (no active task) is passed as None and is never
947
+ # re-read. `decision` resolves once and hands the same dict to all four rules,
948
+ # because each read re-lists `.tezgah/plans/open/` and re-parses the plan on a
949
+ # path every write of every session takes.
950
+ _UNRESOLVED = object()
951
+
952
+
953
+ def _task(cwd, base, task):
954
+ """The active record: the caller's when it resolved one, else resolved here.
955
+ A missing tezgah_task module yields None - the rule is lost, never the
956
+ session, the same fail-open the guarded import takes."""
957
+ if task is not _UNRESOLVED:
958
+ return task
959
+ if tezgah_task is None:
960
+ return None
961
+ return tezgah_task.active(cwd, base)
962
+
963
+
964
+ def task_shell_reason(inp, cwd, base, task=_UNRESOLVED):
965
+ """A deny reason when the active task's phase only reads and this shell
966
+ command writes a file, else None.
967
+
968
+ The route to the hole the phase rule closes, and the one E7b measured: with
969
+ the write tools refused, the armed arm wrote the target with a heredoc
970
+ redirect in 3 of 25 runs. A phase that excludes writes has to exclude them
971
+ however they are made, so this reads the whole SHELL_WRITE table - the
972
+ copiers and in-place editors whose target is a positional argument too. The
973
+ allowlist is not consulted here: `decision` hands a shell command's redirect
974
+ and `tee` targets (write_paths) to task_reason and task_record_reason like a
975
+ write tool's path, while a positional target is not read at all (see
976
+ write_paths), and the same fail-open holds: no record, no requirement."""
977
+ task = _task(cwd, base, task)
978
+ if not task or task["phase"] in tezgah_task.WRITE_PHASES:
979
+ return None
980
+ if not SHELL_WRITE.search(mask(str(inp.get("command") or ""))):
981
+ return None
982
+ return TASK_SHELL_DENY % (task["id"], task["phase"])
983
+
984
+
985
+ def task_record_reason(inp, cwd, base, task=_UNRESOLVED):
986
+ """A deny reason when this write's target IS the active task's record.
987
+
988
+ The other route to the same hole as TASK_UNLOCK_DENY, and the one a write
989
+ tool takes: the record is a file in the repo, so an agent that may write
990
+ files can retype the phase instead of running the CLI. Refused whatever the
991
+ phase and whatever the allowlist says - the record is not inside its own
992
+ scope, because a scope that can widen itself is not one."""
993
+ task = _task(cwd, base, task)
994
+ if not task:
995
+ return None
996
+ record = os.path.relpath(task["path"],
997
+ tezgah_task.repo_root(cwd, base)).replace(os.sep, "/")
998
+ for path in write_paths(inp):
999
+ if tezgah_task.relative(path, cwd, base) == record:
1000
+ return TASK_RECORD_DENY % path
1001
+ return None
1002
+
1003
+
1004
+ def task_reason(inp, cwd, base, task=_UNRESOLVED):
1005
+ """A deny reason when the active task's phase or allowlist excludes this
1006
+ write, else None. No active task -> None: this rule never invents a
1007
+ requirement the user did not set.
1008
+
1009
+ The record is read through tezgah_task, whose import is guarded: a checkout
1010
+ without that module costs this rule, never the session. The paths are the
1011
+ ones write_paths already extracts, so an apply_patch body is checked hunk by
1012
+ hunk like every other write rule's here. A path that resolves outside the
1013
+ repo root is refused by `relative` before any pattern is tried - a `**`
1014
+ allowlist must not reach out of the repo - unless it is the session's own
1015
+ scratch (`scratch_target`: a temp file, a device), which is no task's work;
1016
+ build output inside the repo is a repo path and stays held. The refusal
1017
+ names the file as the call spelled it, because the agent has to recognize
1018
+ its own argument."""
1019
+ task = _task(cwd, base, task)
1020
+ if not task:
1021
+ return None
1022
+ if task["phase"] not in tezgah_task.WRITE_PHASES:
1023
+ return TASK_PHASE_DENY % (task["id"], task["phase"],
1024
+ " or ".join(tezgah_task.WRITE_PHASES))
1025
+ patterns = task["allowed_paths"]
1026
+ if not patterns:
1027
+ return None
1028
+ for path in write_paths(inp):
1029
+ if scratch_target(path, cwd):
1030
+ continue
1031
+ rel = tezgah_task.relative(path, cwd, base)
1032
+ if rel is None or not any(tezgah_task.match(rel, p) for p in patterns):
1033
+ return TASK_SCOPE_DENY % (path, task["id"], ", ".join(patterns))
1034
+ return None
1035
+
1036
+
1037
+ # --- the implementation phase's checkpoint -----------------------------------
1038
+ # What this closes: a large refactor has no single restore point. The reversible
1039
+ # boundary the gate keeps is per file, not per work unit - hooks/tezgah_snapshot.py
1040
+ # captures the pre-write bytes, 200 snapshots at 2 MiB each with the oldest
1041
+ # evicted, and the glossary says so outright: "Not a checkpoint of the session,
1042
+ # only of the files a write was about to change" (docs/glossary.md:143). The plan
1043
+ # machine already names the exact moment the risky work starts - bin/tezgah-task
1044
+ # writes `phase: implementation` and this module then holds writes to the plan's
1045
+ # own allowlist - so that moment gets a named state too: the CLI records
1046
+ # `checkpoint:` as the pre-work commit's sha when the tree was clean, or as
1047
+ # `pending <sha>` when it was not, `<sha>` being the HEAD of that moment.
1048
+ #
1049
+ # Why the refusal is here and not only in the CLI: the CLI stands at the phase
1050
+ # move and is where the sha is written, but the write is the moment the un-named
1051
+ # state would be built on, and a plan can already be in `implementation` when
1052
+ # this rule lands.
1053
+ #
1054
+ # The recorded sha is what the refusal is keyed on, and the tree only says the
1055
+ # two states apart inside it: the sha is written at the phase move, and the commit
1056
+ # the refusal names moves HEAD off it, so the refusal clears itself and the phase
1057
+ # proceeds. Keying on the word `pending` alone did not: the commit left the record
1058
+ # saying `pending`, only bin/tezgah-task rewrites that record, the agent may not
1059
+ # run it, and the phase was then locked until the user happened to re-run the
1060
+ # phase command - a gate that refuses the thing it asks for. Keying on the live
1061
+ # tree alone would refuse the phase's own work, since the first write makes the
1062
+ # tree dirty and it stays that way.
1063
+ #
1064
+ # Fail-open wherever the question cannot be answered: no record, no `checkpoint:`
1065
+ # key, a checkpoint that is a plain sha (the boundary is already real), a `pending`
1066
+ # with no sha, a sha this checkout's HEAD does not name, a repo whose HEAD cannot
1067
+ # be read, and `task-off` above. A refusal has to rest on something a reader can
1068
+ # check, and a rule that cannot answer must not refuse.
1069
+ #
1070
+ # The refusal names the one command that makes the boundary real. That is not the
1071
+ # mistake TASK_PHASE_DENY records: the E7 lesson is a refusal that names how to
1072
+ # take the constraint off (the phase is the user's to move), while the checkpoint
1073
+ # command *satisfies* the requirement - running it is the work the rule asks for,
1074
+ # so naming it is naming what the rule wants rather than a way around it.
1075
+ CHECKPOINT_DENY = (
1076
+ "Checkpoint gate: the active task %s is in phase `implementation` and its "
1077
+ "plan records a pending checkpoint (`checkpoint: pending <sha>`), so this "
1078
+ "write would start the work on top of changes no commit names - and the "
1079
+ "per-file snapshots are not one state to return to. The command that makes "
1080
+ "the boundary real is %s; it moves HEAD off the recorded sha, so running it "
1081
+ "satisfies this rule and moves nothing.")
1082
+
1083
+
1084
+ def checkpoint_reason(inp, cwd, base, task=_UNRESOLVED):
1085
+ """A deny reason when the active task is in `implementation`, its plan's
1086
+ `checkpoint:` waits for a commit (`pending <sha>`), that sha is still HEAD,
1087
+ and the tree still holds uncommitted work - else None.
1088
+
1089
+ The recorded sha, not the word `pending`, is the switch: the commit the
1090
+ refusal names moves HEAD off it, so the refusal clears by itself. The section
1091
+ above carries what this closes, why the record and not the tree is the
1092
+ switch, and every shape that fails open."""
1093
+ if tezgah_task is None:
1094
+ return None
1095
+ command = inp.get("command")
1096
+ if command is not None and not SHELL_WRITE.search(mask(str(command))):
1097
+ # a shell line that changes no file is not this rule's, the same read of
1098
+ # the same shape table task_shell_reason makes
1099
+ return None
1100
+ task = _task(cwd, base, task)
1101
+ if not task or task["phase"] != "implementation":
1102
+ return None
1103
+ sha = tezgah_task.checkpoint_sha(task.get("checkpoint"))
1104
+ if not sha:
1105
+ return None
1106
+ # HEAD read from the ref file, not a fork: this question is asked on every
1107
+ # write of the phase, and an unreadable HEAD fails open.
1108
+ root = tezgah_task.repo_root(cwd, base)
1109
+ if _head(root) != sha:
1110
+ return None
1111
+ import subprocess # deferred: only a plan still pending its checkpoint pays
1112
+ try:
1113
+ listed = subprocess.run(["git", "-C", root, "status", "--porcelain"],
1114
+ capture_output=True, text=True, timeout=5)
1115
+ except (OSError, subprocess.SubprocessError):
1116
+ return None
1117
+ if listed.returncode != 0 or not listed.stdout.strip():
1118
+ return None
1119
+ return CHECKPOINT_DENY % (task["id"], tezgah_task.checkpoint_command(task["path"]))
1120
+
1121
+
1122
+ # --- workspace: per-project tezgah state lives under <repo>/.tezgah only ------
1123
+ # What this closes: sessions wrote `plans/`, `research/` and `analysis/` at the
1124
+ # project root (observed across several unrelated projects on one machine),
1125
+ # where the project's own git picks them up. A root directory the project itself
1126
+ # already tracks files under is the project's own and is left alone.
1127
+ # ponytail: `mkdir research` and a positional shell target (`cp x research/`) are
1128
+ # not read, the same ceiling as write_paths; the first file written through a
1129
+ # tool or a redirect is.
1130
+ WORKSPACE_KINDS = ("plans", "research", "analysis")
1131
+ WORKSPACE_DENY = (
1132
+ "Workspace gate: %s puts tezgah state in the project's own `%s/`. Per-project "
1133
+ "plans, research and analysis live under `.tezgah/%s/` - ignored by the "
1134
+ "project, committed with `git -C .tezgah ...` - so write it there.")
1135
+
1136
+
1137
+ def workspace_reason(inp, cwd, base):
1138
+ """A deny reason when this write lands in a root `plans/`, `research/` or
1139
+ `analysis/` the project does not track, else None."""
1140
+ if tezgah_task is None:
1141
+ return None
1142
+ for path in write_paths(inp):
1143
+ rel = tezgah_task.relative(path, cwd, base)
1144
+ seg = rel.split("/", 1)[0] if rel and "/" in rel else None
1145
+ if seg not in WORKSPACE_KINDS:
1146
+ continue
1147
+ import subprocess # deferred: only a write under one of the three pays
1148
+ try:
1149
+ listed = subprocess.run(
1150
+ ["git", "-C", tezgah_task.repo_root(cwd, base), "ls-files", "--", seg],
1151
+ capture_output=True, text=True, timeout=5)
1152
+ except (OSError, subprocess.SubprocessError):
1153
+ continue
1154
+ if listed.returncode == 0 and not listed.stdout.strip():
1155
+ return WORKSPACE_DENY % (path, seg, seg)
1156
+ return None
1157
+
1158
+
1159
+ # --- lesson lineage: a ledger write in a turn that read untrusted text -------
1160
+ # What this records (security-08): any session can append a standing constraint
1161
+ # to `.tezgah/lessons.md`, and a line written in a turn that read a web page, an
1162
+ # MCP answer or a subagent's report is injected into every later session like
1163
+ # one the user asked for. Owner decision ADR 010: a tainted lesson costs a row,
1164
+ # never a refusal - so this is not a rule, has no `_deny` slug and no switch of
1165
+ # its own beyond `pretooluse-off`. The fold is over every row of the user turn
1166
+ # (`turn_rows`, scoped to the calling subagent), not `tezgah_untrusted.
1167
+ # turn_channel`: that is a pending state the first effect after the read spends,
1168
+ # and a lesson is usually written after other work.
1169
+ # ponytail: a shell write whose body is not a heredoc (`echo x >> lessons.md`),
1170
+ # or whose target is a positional argument (`cp`, `sed -i`), leaves a row with
1171
+ # no `key` or no row at all - the gate cannot read the line it adds - so the
1172
+ # context cannot label that line.
1173
+ def lesson_taint(tool, inp, cwd, session_id, agent=None, shell_body=None):
1174
+ """Write one `lesson_tainted` row per lesson line this call leaves in a
1175
+ ledger that the ledger did not hold, when the user turn already holds a row
1176
+ carrying `source`; a single row with no `key` when the call names no new
1177
+ line the gate can read (an unread body, an edit whose old text is not there,
1178
+ a removal). A write tool's lines are read off the file as it will be once
1179
+ the call lands (`_after_text`), so a partial-line edit names the line it
1180
+ leaves, not its fragment. The keyed rows also go to the ledger's repository
1181
+ index (`tezgah_lessons.write_taint`), which the context labels lines from.
1182
+ Returns nothing: it is never a refusal."""
1183
+ if tezgah_lessons is None or turn_rows is None or not session_id:
1184
+ return
1185
+ t = str(tool or "").lower()
1186
+
1187
+ def norm(path):
1188
+ return os.path.normpath(os.path.join(cwd, os.path.expanduser(path)))
1189
+
1190
+ texts = {norm(path): new for path, _old, new in
1191
+ write_texts(inp if t in WRITE_TOOLS else shell_body)}
1192
+ targets = sorted(p for p in set(texts) | set(map(norm, write_paths(inp)))
1193
+ if tezgah_lessons.is_ledger(p))
1194
+ if not targets:
1195
+ return
1196
+ source = next((row["source"] for row in reversed(
1197
+ turn_rows(session_id, agent=agent)) if row.get("source")), None)
1198
+ if not source:
1199
+ return
1200
+ key_of = tezgah_lessons.lesson_key
1201
+ for path in targets:
1202
+ try:
1203
+ with open(path, encoding="utf-8", errors="replace") as fh:
1204
+ current = fh.read()
1205
+ except OSError:
1206
+ current = ""
1207
+ have = set(map(key_of, tezgah_lessons.entries(current)))
1208
+ new = texts.get(path)
1209
+ if t in WRITE_TOOLS and new is not None:
1210
+ after = _after_text(inp, current)
1211
+ new = new if after is None else after # None: a patch's `+` lines
1212
+ keys = [k for k in dict.fromkeys(map(key_of, tezgah_lessons.entries(
1213
+ new or ""))) if k not in have] or [None]
1214
+ fields = dict(source=source, target=path, agent=agent,
1215
+ workspace=root_for(cwd), id=call_id(tool, inp))
1216
+ for key in keys:
1217
+ note(session_id, "lesson_tainted", source, key=key, **fields)
1218
+ if keys != [None]:
1219
+ write_taint(os.path.dirname(os.path.dirname(path)), have | set(keys),
1220
+ [{"key": k, "source": source, "ts": int(time.time())}
1221
+ for k in keys])
1222
+
1223
+
1224
+ # --- the shell's write body: three write-tool rules reached through a heredoc -
1225
+ # What this closes (the route E7c measured): SKIP_TEST, ATTRIB_LINE and the
1226
+ # prefixed-token credential scan (secret_edit) read the text a write tool lands,
1227
+ # and the write tools are disjoint from BASH_TOOLS, so a heredoc that wrote a
1228
+ # test skip, an attribution line or a key into a file was refused by nothing -
1229
+ # and E7c watched the armed arm take exactly that route once the write tools
1230
+ # were refused (3 of 25 runs wrote the target with `cat > app/api.py <<'EOF'`).
1231
+ # Each of the three now also reads the body a shell command writes, and the body
1232
+ # is the RAW text because mask() blanks heredoc bodies by design: a command that
1233
+ # merely names a rule must not be denied.
1234
+ #
1235
+ # The shape test is the task rule's own SHELL_WRITE, read off the masked text, so
1236
+ # a quoted `>` is not a redirect; the bodies come from the raw text under it, one
1237
+ # per marker, with an unterminated heredoc skipped the way tezgah_integrity's own
1238
+ # blanker skips it (the ceiling the two share).
1239
+ # ponytail: only a heredoc's body is read. A write whose content is a quoted
1240
+ # argument (`echo "Co-Authored-By: x" > f`) is not, and cannot be: the credit
1241
+ # there sits inside a string no line-start anchor can see, while widening this to
1242
+ # the whole command text would deny a search for the form (`grep "Co-Authored-By"
1243
+ # x > out`). The measured route is the heredoc, and that route is closed.
1244
+ SHELL_TARGET = re.compile(r">>?(?![&=])\s*(\S+)|(?<![\w-])tee\s+(?:-\S+\s+)*(\S+)")
1245
+
1246
+
1247
+ def shell_target(command):
1248
+ """The file a shell command's own text writes, or "" - the real redirect's
1249
+ target, or `tee`'s argument, sliced from the raw text at the offset the masked
1250
+ match proved (masking keeps length), so a quoted `>` is not a redirect and
1251
+ `> /dev/null` is not a file.
1252
+
1253
+ The one reader of a shell write's target: `write_paths` uses it for the file
1254
+ a shell call changes (which is what makes the gate capture a pre-state for
1255
+ that write and `_post_write` hash its after-state), and `shell_write_body`
1256
+ uses it for the file whose body the three write-tool twins read. Empty for a
1257
+ command that writes nothing, and for every write whose target is a positional
1258
+ argument rather than a redirect - see `write_paths`.
1259
+
1260
+ ponytail: a target that is a QUOTED name (`printf a > "a;b.txt"`) is not read
1261
+ at all - the slice is taken by offset from the masked text, and masking blanks
1262
+ a quoted string, so the match never lands. Measured 2026-09-19: the call
1263
+ captures nothing and its write stays out of the freshness fold, the same hole
1264
+ the separator trim closed for an unquoted chain. Closing it means reading the
1265
+ target off the raw text with a quote-aware scan."""
1266
+ c = str(command or "")
1267
+ if not c:
1268
+ return ""
1269
+ masked = mask(c)
1270
+ if not SHELL_WRITE.search(masked):
1271
+ return ""
1272
+ m = SHELL_TARGET.search(masked)
1273
+ if not m:
1274
+ return ""
1275
+ start, end = m.span(1) if m.group(1) else m.span(2)
1276
+ raw = c[start:end]
1277
+ # `\S+` runs to the next whitespace, so the separator of a chained command
1278
+ # came with the name: `printf a > x; printf b > y` sliced `x;`, a path
1279
+ # nothing is ever written to - no pre-state captured, no after-state hashed,
1280
+ # and the write invisible to the freshness fold. A quoted target keeps its
1281
+ # name whole, so only an unquoted slice is trimmed.
1282
+ if raw[:1] in ("'", '"'):
1283
+ return raw.strip("'\"")
1284
+ return raw.strip("'\"").rstrip(";|&)")
1285
+
1286
+
1287
+ def shell_write_body(command, cwd=None):
1288
+ """The `{file_path, content}` a shell command writes into a file, or None when
1289
+ this command writes no file's content of its own.
1290
+
1291
+ The shape test is SHELL_WRITE on the masked text, so a quoted `>` is not a
1292
+ redirect; the path is that real redirect's target, or `tee`'s argument, sliced
1293
+ from the raw text at the offset the masked match proved (masking keeps
1294
+ length), and resolved against the call's own directory - so a write to a test
1295
+ file is compared against the file the call named and not against whichever one
1296
+ the hook process happens to sit in."""
1297
+ c = str(command or "")
1298
+ if not c or not SHELL_WRITE.search(mask(c)):
1299
+ return None
1300
+ # the bodies bash reads (tezgah_integrity._heredocs): a `<<'X'` inside a
1301
+ # quoted string or a comment is not one (review R1)
1302
+ bodies = heredoc_bodies(c)
1303
+ if not bodies:
1304
+ return None
1305
+ path = shell_target(c)
1306
+ if path and not os.path.isabs(path):
1307
+ path = os.path.join(cwd or os.getcwd(), path)
1308
+ return {"file_path": path, "content": "\n".join(bodies)}
1309
+
1310
+
1311
+ # --- ordering: a commit while the newest check failed -----------------------
1312
+ # The one rule here that asserts a relation between two actions rather than
1313
+ # reading one call plus a ledger tail - the shape FAVA found in 90% of real
1314
+ # agent-instruction projects ("do not commit before running the tests") and the
1315
+ # one this gate did not carry (C9). It is the contract's own claim - nothing is
1316
+ # reported done unless the output was seen - given a mechanical half at the one
1317
+ # moment a claim is written into the repository's history.
1318
+ #
1319
+ # The reader is the Stop rule's own fold over the tail (`_last_verify` on the
1320
+ # last ORDER_TAIL rows), and the rule is structurally incapable of firing in
1321
+ # either direction that would punish honest work: no check at all folds to None
1322
+ # and a passing newest check folds to "ok", so a commit in a session that ran no
1323
+ # check, and a commit after a green run, both pass. Only "fail" refuses - the
1324
+ # newest check in this session rejected the tree the commit would freeze - and
1325
+ # the refusal names that check. What lifts it is making the newest check pass
1326
+ # (fix what it reported and run it again), so the reason names no command, the
1327
+ # way the task refusals do not: a refusal is a boundary or it is an instruction.
1328
+ # ponytail: the state is session-wide, not turn-scoped, so a probe the user asked
1329
+ # to fail and a check that failed before this turn's prompt both count; the
1330
+ # repair is the same either way, and a turn-scoped reader would let a commit
1331
+ # through on a tree the failed check still describes.
1332
+ ORDER_TAIL = 200
1333
+ COMMIT_CMD = re.compile(
1334
+ r"(?:^|[|;&]\s*|\s)git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*commit(?=\s|$|[|;&])")
1335
+ ORDER_DENY = (
1336
+ "Commit order denied: the newest check in this session failed, and a commit "
1337
+ "is a claim that the tree passed. What would be frozen is the state that "
1338
+ "check just rejected - the newest failing one was %s - so fix what it "
1339
+ "reported and run it again, commit once the newest check is green, or say "
1340
+ "plainly that it is failing and why the commit is wanted anyway.")
1341
+
1342
+
1343
+ def commit_order_reason(command, session_id):
1344
+ """A deny reason when this session's newest check failed and this command is
1345
+ a commit, else None.
1346
+
1347
+ Not a rule about commits: with no check in the tail the fold is None and
1348
+ nothing is refused, which is what keeps an ordinary commit in a session that
1349
+ ran nothing - a docs edit, a message-only change - out of it."""
1350
+ if not (session_id and _last_verify):
1351
+ return None
1352
+ if not COMMIT_CMD.search(mask(str(command or ""))):
1353
+ return None
1354
+ rows = events(session_id, tail=ORDER_TAIL)
1355
+ if _last_verify(rows) != "fail":
1356
+ return None
1357
+ return ORDER_DENY % _failed_check(rows)
1358
+
1359
+
1360
+ # --- constraint drift: a long turn loses the rules it started with ----------
1361
+ # The re-statement that keeps the rules alive rides the user prompt
1362
+ # (tezgah_context.context_for writes PROMPT_REMINDER on every turn), so the
1363
+ # stretch nothing covers is one turn that runs long: by step 25 the prompt that
1364
+ # armed the rules is dozens of tool results back and has stopped steering. The
1365
+ # threshold counts work rows in the CURRENT user turn - the same rows counters()
1366
+ # counts, a run/an edit/a check - which a normal turn reaches a handful of, and
1367
+ # the mark is per turn: one re-statement in a turn that drifted is useful, the
1368
+ # same one in every short turn is noise the agent learns to skip.
1369
+ #
1370
+ # It is delivered as a refusal - the gate's reason string - and nowhere else: no
1371
+ # host's result side carries it. An internal plan measured the result-channel delivery
1372
+ # and its pre-registered counter-metric sent it back here.
1373
+ DRIFT_STEPS = 25
1374
+ # How far back the count reads: the same 200-row bound the repeat guards read, so
1375
+ # one gated call parses at most that many rows. Past that bound the window holds
1376
+ # neither the turn's own marker nor the one the notice wrote, so the read falls
1377
+ # back to the turn's own start (the module's own turn-scoped reader).
1378
+ DRIFT_TAIL = 200
1379
+ DRIFT_DENY = (
1380
+ "Long turn (%d work rows in, past the %d this notice waits for): the "
1381
+ "constraints this session was armed with are still in force. %s This is a "
1382
+ "re-statement, not a violation report: re-issue this call unchanged and "
1383
+ "carry on.")
1384
+
1385
+
1386
+ def drift_text(cwd, session_id):
1387
+ """The body of the drift notice: the delta since this turn began where the
1388
+ context module can produce one, else the standing constraints themselves.
1389
+
1390
+ A long turn's actual failure is often that a value moved under it - HEAD, a
1391
+ lessons count, the plan set - and a re-statement cannot say that: it repeats
1392
+ what the session was armed with rather than what changed since. So the newer
1393
+ half wins where it is available: `tezgah_context.constraint_notice` folds the
1394
+ per-turn stamp the prompt path writes into one line ("state since your last
1395
+ turn: ..."), and it hands back this module's own `constraints_line` verbatim
1396
+ when it has no stamp for this session, so a session it cannot speak about
1397
+ reads exactly as before. Imported inside the call (only the call that crosses
1398
+ DRIFT_STEPS pays for it) and guarded twice over: a checkout without the
1399
+ helper, or a stamp it cannot read, falls back to the re-statement, because a
1400
+ crash here would cost the host's whole envelope (tezgah_guard.safe wraps the
1401
+ adapter, and this is the one place a cheap fallback buys more than it costs)."""
1402
+ try:
1403
+ from tezgah_context import constraint_notice
1404
+ return constraint_notice(cwd, session_id)
1405
+ except Exception:
1406
+ return constraints_line(cwd)
1407
+
1408
+
1409
+ def constraints_line(cwd):
1410
+ """The standing constraints as one line, from tezgah_policy's own text.
1411
+
1412
+ `subagent_core` is the tested short form of every always-on rule (its bold
1413
+ label plus its opening clause, under the same kill-switch filtering), and its
1414
+ header already carries the on-demand rules' pointer, so the notice can
1415
+ neither name a rule that is off nor miss one that is on: it is a
1416
+ re-statement, never a second copy of the contract. POINTERS is not appended
1417
+ again - it printed the On-demand paragraph twice (audit CHAT-06). Imported
1418
+ inside the call because only the call that crosses DRIFT_STEPS pays for it."""
1419
+ from tezgah_context import core_for, render, subagent_core
1420
+ return " ".join(render(subagent_core(core_for(cwd)[0])).split())
1421
+
1422
+
1423
+ def drift_reason(tool, inp, cwd, session_id):
1424
+ """A one-shot re-statement of the standing constraints when this user turn
1425
+ has run past DRIFT_STEPS work rows and this call is an effect, else None.
1426
+
1427
+ What this deliberately is NOT: a detector of the rule the user meant. A
1428
+ PreToolUse payload carries the tool call, not the prompt - no host hook sees
1429
+ the text the user typed - so "which rule is being forgotten" would be a guess
1430
+ about intent wearing a check's clothes. Re-stating the standing constraints,
1431
+ or the delta since the turn began where the context module has one, is the
1432
+ honest half, and it is the whole of what this does (see drift_text). The host
1433
+ gives a PreToolUse hook no non-blocking way to reach the model either (the
1434
+ reason string is the only channel), so the notice arrives as a refusal whose
1435
+ reason is the re-statement, and the mark is written here - before the deny -
1436
+ so the identical call passes on the next attempt."""
1437
+ if off("reminder-off") or not effectful(tool, inp or {}):
1438
+ return None
1439
+ if not session_id:
1440
+ return None
1441
+ rows = events(session_id, tail=DRIFT_TAIL)
1442
+ start = _turn_start(rows)
1443
+ rows = rows[start:] if start else turn_rows(session_id)
1444
+ if any(row.get("kind") == "drift" for row in rows):
1445
+ return None
1446
+ steps = sum(1 for row in rows if row.get("kind") in STEP_KINDS)
1447
+ if steps < DRIFT_STEPS:
1448
+ return None
1449
+ note(session_id, "drift", str(steps), workspace=root_for(cwd))
1450
+ return DRIFT_DENY % (steps, DRIFT_STEPS, drift_text(cwd, session_id))
1451
+
1452
+
1453
+ def effectful(tool, inp):
1454
+ """True when this call is one the constraints are about: a write tool, a
1455
+ git/gh command that lands an artifact, or an MCP effect
1456
+ (`tezgah_integrity.mcp_class`, which reads the name as the host matcher does,
1457
+ case-sensitive). A read changes nothing, so the notice spent on it would be
1458
+ spent where no rule applies."""
1459
+ t = str(tool or "").lower()
1460
+ if t in WRITE_TOOLS:
1461
+ return True
1462
+ if t in BASH_TOOLS:
1463
+ return bool(WRITE_CMD.search(mask(str(inp.get("command") or ""))))
1464
+ return mcp_class(tool) is not None
1465
+
1466
+
1467
+ # --- plan required: work that spans files on main has no plan ----------------
1468
+ # What this closes: a turn that wrote four product files - a hook, its tests, its
1469
+ # docs and the changelog - straight onto `main`, with the plan opened afterwards.
1470
+ # The task rule constrains a write only through a record the user made, so with
1471
+ # no active task every product write passes and "work for a plan happens on its
1472
+ # branch" is advice rather than a mechanism. Here the checkout is the record: on
1473
+ # `main`/`master` the third distinct product file a single turn writes is
1474
+ # refused, while a one- or two-file fix stays free - a plan is what makes work
1475
+ # that spans files reviewable and closeable.
1476
+ #
1477
+ # The count is a fold over the turn's own write rows (`turn_rows`), so it needs
1478
+ # no state of its own, and the call in hand contributes the files it names before
1479
+ # the comparison: an `apply_patch` naming three product files is the third file by
1480
+ # itself.
1481
+ #
1482
+ # Fail-open wherever the question cannot be answered: no session, no ledger, a
1483
+ # checkout that is not a git repository, a detached HEAD (no branch to name), and
1484
+ # a repo carrying the `.no-plan-gate` mark all pass - the direction every rule
1485
+ # here takes, because a refusal has to rest on something a reader can check.
1486
+ #
1487
+ # ponytail: a PRIOR write recorded through the shell is a `run` row whose detail
1488
+ # is the command, so it is not counted; only the call in hand contributes a shell
1489
+ # target (`write_paths` reads the redirect). The turn this rule answers is the
1490
+ # write-tool turn, and reading a shell command's target off a stored command line
1491
+ # would be a second reader of the same table, not a new fact.
1492
+ PLAN_BRANCHES = ("main", "master")
1493
+ # The product paths the count reads, relative to the checkout holding the write:
1494
+ # the code and docs a plan exists to scope. `.tezgah/` - and every other
1495
+ # directory - is not one of them, and neither is a path outside any git checkout
1496
+ # or a checkout outside every configured root (a scratch fixture repo).
1497
+ PLAN_DIRS = ("hooks", "tests", "bin", "skills", "docs")
1498
+ PLAN_FILES = ("statusline.py", "MANIFEST")
1499
+ # The third file: two writes in a turn are a fix, three are work that spans files.
1500
+ PLAN_WRITES = 3
1501
+ PLAN_DENY = (
1502
+ "This turn has written %d distinct product files while the checkout is on "
1503
+ "`%s`. Work that spans files on `main`/`master` has no plan to review or "
1504
+ "close, so the third product file in one turn is refused: open a plan with "
1505
+ "the `plan-add` skill (`/tezgah:plan-add <description>`) and do the work on "
1506
+ "the `plan/NNN-slug` branch it creates. A one- or two-file fix stays free. "
1507
+ "Only `hooks/`, `tests/`, `bin/`, `skills/`, `docs/`, `statusline.py` and "
1508
+ "`MANIFEST` are counted; a `.tezgah/` path never is.")
1509
+
1510
+
1511
+ def _gitdir(root):
1512
+ """The `.git` directory the checkout at `root` keeps its refs in, or "" when
1513
+ `root` does not name one: no `.git` entry, or a `.git` file that is not the
1514
+ `gitdir:` pointer a worktree writes."""
1515
+ dot = os.path.join(root, ".git")
1516
+ if not os.path.isfile(dot):
1517
+ return dot
1518
+ try:
1519
+ with open(dot, encoding="utf-8", errors="replace") as fh:
1520
+ line = fh.readline().strip()
1521
+ except OSError:
1522
+ return ""
1523
+ if not line.startswith("gitdir:"):
1524
+ return ""
1525
+ target = line[len("gitdir:"):].strip()
1526
+ return target if os.path.isabs(target) else os.path.join(root, target)
1527
+
1528
+
1529
+ def _branch(root):
1530
+ """The branch the checkout at `root` is on, or "" when there is none to name:
1531
+ not a git repository (no `.git`), an unreadable HEAD, or a detached HEAD.
1532
+
1533
+ Read from `.git/HEAD` rather than a `git` fork: the gate runs this on every
1534
+ product write, and one file read is the whole cost. A worktree's `.git` is a
1535
+ file naming the real gitdir, which is followed."""
1536
+ gitdir = _gitdir(root)
1537
+ if not gitdir:
1538
+ return ""
1539
+ try:
1540
+ with open(os.path.join(gitdir, "HEAD"), encoding="utf-8",
1541
+ errors="replace") as fh:
1542
+ ref = fh.readline().strip()
1543
+ except OSError:
1544
+ return ""
1545
+ head = "ref: refs/heads/"
1546
+ return ref[len(head):] if ref.startswith(head) else ""
1547
+
1548
+
1549
+ # A commit id as git writes it: 40 hex for the default object store, 64 for
1550
+ # sha256. Both are accepted so the reader does not fail open on a repo whose
1551
+ # object format the module never had to know.
1552
+ _SHA = re.compile(r"[0-9a-fA-F]{40,64}")
1553
+
1554
+
1555
+ def _packed_sha(gitdir, ref):
1556
+ """The sha `gitdir/packed-refs` names for `ref`, or "": a ref `git gc` folded
1557
+ out of its loose file still resolves, which is what keeps the reader from
1558
+ failing open on a repacked repository. The header line and a tag's `^`-peeled
1559
+ line carry no `ref` match, so only the ref's own line can answer."""
1560
+ try:
1561
+ with open(os.path.join(gitdir, "packed-refs"), encoding="utf-8",
1562
+ errors="replace") as fh:
1563
+ for line in fh:
1564
+ sha, _, name = line.strip().partition(" ")
1565
+ if name.strip() == ref and _SHA.fullmatch(sha):
1566
+ return sha
1567
+ except OSError:
1568
+ pass
1569
+ return ""
1570
+
1571
+
1572
+ def _head(root):
1573
+ """The sha `HEAD` names for the checkout at `root`, or "" when the question
1574
+ cannot be answered: not a git repository, an unreadable HEAD, a ref with
1575
+ neither a loose file nor a packed-refs line, or a detached HEAD holding
1576
+ something that is not a sha.
1577
+
1578
+ Read from `.git/HEAD` - the ref file, or the packed-refs line beside it -
1579
+ rather than a `git` fork, the way `_branch` reads the branch: the checkpoint
1580
+ rule asks this on every write of an `implementation` phase, and an
1581
+ unanswerable question must pass. ponytail: a worktree's packed refs live in
1582
+ the common dir, not in the worktree gitdir this reads, so a repacked ref in a
1583
+ linked worktree falls back to "" - the fail-open direction."""
1584
+ gitdir = _gitdir(root)
1585
+ if not gitdir:
1586
+ return ""
1587
+ try:
1588
+ with open(os.path.join(gitdir, "HEAD"), encoding="utf-8",
1589
+ errors="replace") as fh:
1590
+ ref = fh.readline().strip()
1591
+ except OSError:
1592
+ return ""
1593
+ if not ref.startswith("ref: "):
1594
+ return ref if _SHA.fullmatch(ref) else ""
1595
+ name = ref[len("ref: "):].strip()
1596
+ try:
1597
+ with open(os.path.join(gitdir, name), encoding="utf-8",
1598
+ errors="replace") as fh:
1599
+ sha = fh.readline().strip()
1600
+ except OSError:
1601
+ sha = ""
1602
+ return sha if _SHA.fullmatch(sha) else _packed_sha(gitdir, name)
1603
+
1604
+
1605
+ def _checkout(path, cwd):
1606
+ """`(top, rel)` for a write target: the nearest git checkout holding it and
1607
+ its path inside that checkout, or None when no checkout holds it or the path
1608
+ lies under a `.tezgah/` (which keeps a repository of its own and is never a
1609
+ product path).
1610
+
1611
+ The branch question belongs to this checkout, not to the session's cwd: a
1612
+ session on `main` writing into a linked worktree on a plan branch is plan
1613
+ work, and one on a plan branch writing into a `main` checkout is not. A
1614
+ relative path is resolved against the call's own `cwd`."""
1615
+ text = str(path or "").strip()
1616
+ if not text or not cwd and not os.path.isabs(text):
1617
+ return None
1618
+ full = os.path.realpath(os.path.join(cwd or "", text))
1619
+ if ".tezgah" in full.split(os.sep):
1620
+ return None
1621
+ cur = os.path.dirname(full)
1622
+ while not os.path.lexists(os.path.join(cur, ".git")):
1623
+ parent = os.path.dirname(cur)
1624
+ if parent == cur:
1625
+ return None
1626
+ cur = parent
1627
+ return cur, os.path.relpath(full, cur)
1628
+
1629
+
1630
+ def _product(rel):
1631
+ """True when a checkout-relative path is one a plan exists to scope."""
1632
+ parts = rel.split(os.sep)
1633
+ return parts[0] in PLAN_DIRS or len(parts) == 1 and parts[0] in PLAN_FILES
1634
+
1635
+
1636
+ def plan_reason(inp, cwd, session_id):
1637
+ """A deny reason when this write would be a turn's third distinct product
1638
+ file in a checkout on `main`/`master`, else None. The section above carries
1639
+ the trigger, the counted paths and the fail-open direction."""
1640
+ if not session_id or turn_rows is None:
1641
+ return None
1642
+ targets = {}
1643
+ for path in write_paths(inp):
1644
+ hit = _checkout(path, cwd)
1645
+ if hit and _product(hit[1]) and root_for(hit[0]):
1646
+ targets.setdefault(hit[0], set()).add(hit[1])
1647
+ if not targets:
1648
+ return None
1649
+ rows = None
1650
+ for top, written in targets.items():
1651
+ branch = _branch(top)
1652
+ if branch not in PLAN_BRANCHES \
1653
+ or os.path.exists(os.path.join(top, ".no-plan-gate")):
1654
+ continue
1655
+ if rows is None:
1656
+ # a row's `target` is already absolute against the cwd it was
1657
+ # recorded from; an older row without one resolves against this cwd
1658
+ rows = [_checkout(row.get("target") or row.get("detail"), cwd)
1659
+ for row in turn_rows(session_id) if row.get("kind") == "edit"]
1660
+ written |= {rel for hit in rows if hit and hit[0] == top
1661
+ for rel in (hit[1],) if _product(rel)}
1662
+ if len(written) >= PLAN_WRITES:
1663
+ return PLAN_DENY % (len(written), branch)
1664
+ return None
1665
+
1666
+
1667
+ # ---------------------------------------------------------------------------
1668
+ # Control plane. Every other rule here is held by a file the session can write:
1669
+ # a kill switch is a file under OFF_DIRS, the Stop rule reads a ledger in the
1670
+ # cache, the hooks are wired by files in each host's config. The REPORT measured
1671
+ # 12 of 12 tamper shapes passing this gate (`touch ~/.config/tezgah/verify-off`,
1672
+ # a Write to the same file, `rm .git/hooks/pre-commit`, an `echo` into the
1673
+ # ledger, `git add -f .tezgah/x`, ...). The threat model is a cooperative but
1674
+ # fallible agent (SECURITY.md): one that reaches for a switch it was shown,
1675
+ # never one that writes a Python one-liner to get around the gate - that route,
1676
+ # and any other interpreter, is named as a residual rather than closed.
1677
+ #
1678
+ # The deny names no command: with the unlock in the refusal the E7 block
1679
+ # measured 25 of 25 armed runs disabling the gate. What the user runs to change
1680
+ # the state is theirs to know.
1681
+ CONTROL_DENY = (
1682
+ "Control plane: this call changes %s. That state is the user's, not the "
1683
+ "session's: they change it in their own terminal. Say what you wanted "
1684
+ "changed and why, and carry on without it.")
1685
+ # The two targets plan 050's replay of three weeks of real ledgers (2026-10-07,
1686
+ # rows 1, 3, 5 and 8) found refused without the user's say-so more than once a
1687
+ # week: its falsifier (REPORT.md 5430, ADR 018) keeps the tamper evidence and
1688
+ # drops the refusal. A call that changes one of these and nothing else the rule
1689
+ # protects passes and leaves a `disarm` row (`decision`); a refusing target in
1690
+ # the same call still refuses. Only a path strictly under the open plans counts:
1691
+ # their directory or an ancestor of it takes every plan at once (HELD).
1692
+ OPEN_PLAN = "an open plan"
1693
+ FORCED_ADD = "a private `.tezgah/` path forced into the project's history"
1694
+ CONTROL_EVIDENCE = frozenset((OPEN_PLAN, FORCED_ADD))
1695
+ HELD = "a directory that holds tezgah's control state"
1696
+ # A word the shell expands into names (a glob or a brace): directly under the
1697
+ # open plans it names every plan, not one.
1698
+ GLOB_CHARS = re.compile(r"[*?\[{]")
1699
+
1700
+
1701
+ def _refusing(label):
1702
+ """True for a control label that refuses rather than leaves evidence."""
1703
+ return bool(label) and label not in CONTROL_EVIDENCE
1704
+
1705
+
1706
+ # The CLIs that change control state, by basename with or without `.py`, and the
1707
+ # first argument that makes a call a change: None is any call, "" any argument.
1708
+ # `tezgah-gate decide` writes a genuine `began` row with `check=1`; the capture
1709
+ # CLI writes snapshot rows; pony and adhd write CONFIG_DIR; `tezgah-context
1710
+ # attest` re-attests a session, and a clean result clears its drift mark. The
1711
+ # task CLI is not here: it is the task rule's (TASK_CHANGE), which `task-off`
1712
+ # removes, and that switch is one of the files this rule protects.
1713
+ # `tezgah-context user_prompt` is a prompt event, and a prompt naming a switch
1714
+ # writes the `authorized` row the switch latch honors (tezgah_paths.off).
1715
+ CONTROL_CLIS = {"tezgah-gate": ("decide",), "tezgah-capture": "",
1716
+ "tezgah-pony": "", "tezgah-adhd": ("on", "off"),
1717
+ "tezgah-context": ("attest", "user_prompt")}
1718
+ # The hook entries a host runs, by basename without `.py`: run from a tool call
1719
+ # with a forged payload on stdin, any of them writes genuine rows - a prompt's
1720
+ # `authorized` row, a `began` row, a Stop verdict. A host adapter is `hook.py`
1721
+ # under `hosts/<host>/` (HOOK_HOSTS); its launchers carry their own names.
1722
+ HOOK_ENTRIES = frozenset(("projects-auto-init", "projects-pretooluse",
1723
+ "projects-posttooluse", "projects-stop",
1724
+ "tezgah-codex-hook", "tezgah-cursor-hook"))
1725
+ HOOK_HOSTS = frozenset(("codex", "cursor", "omp"))
1726
+ # Programs that change a path named in their arguments. Every positional is a
1727
+ # target, except for the copiers, whose last one is (`cp switch /tmp` reads it).
1728
+ # `sed`/`perl` count only with an in-place flag.
1729
+ CONTROL_PROGRAMS = frozenset(("touch", "rm", "rmdir", "unlink", "shred",
1730
+ "truncate", "chmod", "chown", "chgrp", "mv",
1731
+ "mkdir", "tee", "cp", "ln", "install", "rsync",
1732
+ "dd", "sed", "perl"))
1733
+ CONTROL_COPIERS = frozenset(("cp", "mv", "ln", "install", "rsync"))
1734
+ # The programs that remove, disable or move a path. A directory that holds
1735
+ # protected state, a repository's husky hooks and the open plans (evidence) are
1736
+ # judged for these only: husky's own setup writes `.husky/pre-commit` with a
1737
+ # redirect.
1738
+ CONTROL_REMOVERS = frozenset(("rm", "rmdir", "unlink", "shred", "truncate",
1739
+ "chmod", "chown", "chgrp", "mv"))
1740
+ # Options whose value is a write target (`cp -t dir`, `curl -o file`).
1741
+ _INTO = ("-t", "--target-directory")
1742
+ OPTION_TARGETS = {"cp": _INTO, "mv": _INTO, "ln": _INTO, "install": _INTO,
1743
+ "curl": ("-o", "--output", "--output-dir"),
1744
+ "wget": ("-O", "--output-document"), "unzip": ("-d",)}
1745
+ INTERPRETERS = frozenset(("python", "python3", "sh", "bash", "zsh", "dash"))
1746
+ SHELL_NAMES = frozenset(("sh", "bash", "zsh", "dash", "ksh"))
1747
+ # Words that open a compound command: the command after one is read as if it
1748
+ # were not there (`if touch x; then`, `! rm x`, `do touch x`). `[`/`]` are not
1749
+ # here: the test command is skipped in `_program`, so the command after it in
1750
+ # an `if` line is read.
1751
+ SHELL_KEYWORDS = frozenset(("if", "then", "elif", "else", "do", "while",
1752
+ "until", "!", "{", "time"))
1753
+ SHELL_OPS = re.compile(r"^[;&|()<>]+$")
1754
+ # The cache subtrees whose rows the rules read: the evidence ledger, the session
1755
+ # store, the switch baseline (tezgah_context.disarmed), the gate mark, and the
1756
+ # marks the status line reads for a drifted hook entry
1757
+ # (tezgah_attest.drift_mark) and a core that failed to import
1758
+ # (tezgah_guard.import_crash_mark).
1759
+ CONTROL_CACHE = ("evidence", "sessions", "switches", "gate-inactive",
1760
+ "harness-drift", "import-crash", "workspace-index.json")
1761
+ # Top-level keys tezgah writes into Claude's settings (bin/tezgah-setup
1762
+ # wire_claude_statusline, wire_claude_attribution), and the one that turns every
1763
+ # hook off at once; any other key or entry is tezgah's only when it names tezgah.
1764
+ CLAUDE_OWNED = ("statusLine", "attribution", "disableAllHooks")
1765
+ PATCH_DELETE = re.compile(r"(?m)^\*\*\* Delete File: (\S.*?)\s*$")
1766
+
1767
+
1768
+ def _real(path, cwd):
1769
+ """`path` as an absolute path: `~`, `$HOME` and the XDG variables expanded
1770
+ the way the shell would, relative to `cwd`, its directory resolved and its
1771
+ last component kept. A hook file the installer symlinked into a host's
1772
+ config is still that host's file, and the checkout it points at is not."""
1773
+ path = os.path.expandvars(os.path.expanduser(str(path)))
1774
+ parent, name = os.path.split(os.path.normpath(os.path.join(cwd or os.sep, path)))
1775
+ return os.path.join(os.path.realpath(parent), name)
1776
+
1777
+
1778
+ def _full(path, cwd):
1779
+ """`path` with every link resolved, the last component's too: a write
1780
+ through a link lands on what it points at."""
1781
+ path = os.path.expandvars(os.path.expanduser(str(path)))
1782
+ return os.path.realpath(os.path.join(cwd or os.sep, path))
1783
+
1784
+
1785
+ def _under(path, root):
1786
+ return path == root or path.startswith(root.rstrip(os.sep) + os.sep)
1787
+
1788
+
1789
+ def _install_trees(cwd):
1790
+ """The trees tezgah's hooks run from: the install prefix, the Claude plugin
1791
+ copies (`_registration`), and the tree this module was loaded from - unless
1792
+ the session works inside that tree, which is then a checkout of tezgah
1793
+ being developed, not an install."""
1794
+ prefix = os.environ.get("TEZGAH_PREFIX") or os.path.join(
1795
+ os.environ.get("XDG_DATA_HOME") or os.path.join(
1796
+ os.path.expanduser("~"), ".local", "share"), "tezgah")
1797
+ trees = [os.path.realpath(prefix)]
1798
+ plugin = os.path.realpath(PLUGIN_ROOT)
1799
+ if not _under(os.path.realpath(cwd), plugin):
1800
+ trees.append(plugin)
1801
+ return trees
1802
+
1803
+
1804
+ def _tops(cwd):
1805
+ """The checkouts the session works in: the nearest `.git`-holding ancestor
1806
+ of `cwd`, and the main checkout when that is a linked worktree (its hooks
1807
+ live there). A clone elsewhere is not one of them."""
1808
+ out, here = [], os.path.realpath(cwd or os.sep)
1809
+ while True:
1810
+ if os.path.exists(os.path.join(here, ".git")):
1811
+ out.append(here)
1812
+ break
1813
+ if os.path.dirname(here) == here:
1814
+ break
1815
+ here = os.path.dirname(here)
1816
+ main = linked_main(cwd) if cwd else None
1817
+ return out + ([os.path.realpath(main)] if main else [])
1818
+
1819
+
1820
+ def _held(origin):
1821
+ """The protected paths a remove or move of a directory holding one takes
1822
+ with it: tezgah's configuration, the switches that exist, the cache state,
1823
+ the install trees, and the session checkout's git hooks and open plans. A
1824
+ single plan under the open plans is evidence (OPEN_PLAN); the directory
1825
+ and its ancestors take every plan at once and stay refused."""
1826
+ out = [os.path.realpath(CONFIG_DIR)]
1827
+ out += [p for d in OFF_DIRS for p in (os.path.join(os.path.realpath(d), n)
1828
+ for n in SWITCHES) if os.path.exists(p)]
1829
+ out += [os.path.join(c, d) for c in {os.path.realpath(CACHE),
1830
+ os.path.realpath(fallback_cache())}
1831
+ for d in CONTROL_CACHE]
1832
+ out += _install_trees(origin)
1833
+ for top in _tops(origin):
1834
+ out += [os.path.join(top, ".git", "hooks"),
1835
+ os.path.join(top, ".tezgah", "plans", "open")]
1836
+ return out
1837
+
1838
+
1839
+ def _registration(real):
1840
+ """'shared' for a file tezgah writes entries into beside the user's,
1841
+ 'owned' for a hook file that is wholly tezgah's, else None. A Claude
1842
+ `settings*.json` counts in the user's own `~/.claude` or in a project under
1843
+ a root, not in a fixture under /tmp or another temp HOME."""
1844
+ claude = HOST_DIRS["claude"]
1845
+ name = os.path.basename(real)
1846
+ shared = (os.path.join(HOST_DIRS["codex"], "hooks.json"),
1847
+ os.path.join(HOST_DIRS["codex"], "config.toml"),
1848
+ os.path.join(HOST_DIRS["cursor"], "hooks.json"),
1849
+ os.path.join(claude, "plugins", "installed_plugins.json"),
1850
+ os.path.join(claude, "plugins", "known_marketplaces.json"))
1851
+ if (name in ("settings.json", "settings.local.json")
1852
+ and ".claude" in real.split(os.sep)
1853
+ and (os.path.dirname(real) == os.path.realpath(claude)
1854
+ or root_for(real))) \
1855
+ or real in {_real(p, None) for p in shared}:
1856
+ return "shared"
1857
+ owned = [os.path.join(HOST_DIRS["opencode"], d, "tezgah.js")
1858
+ for d in ("plugins", "plugin")]
1859
+ owned.append(os.path.join(HOST_DIRS["omp"], "agent", "hooks", "pre",
1860
+ "tezgah-hook.ts"))
1861
+ cache = os.path.realpath(os.path.join(claude, "plugins", "cache"))
1862
+ if real in {_real(p, None) for p in owned} or (
1863
+ _under(real, cache) and "tezgah" in real[len(cache):].lower()):
1864
+ return "owned"
1865
+ return None
1866
+
1867
+
1868
+ def _target_label(real, remove, origin):
1869
+ """`control_target` for one spelling of the path."""
1870
+ name, parent = os.path.basename(real), os.path.dirname(real)
1871
+ if name in SWITCHES and parent in {os.path.realpath(d) for d in OFF_DIRS}:
1872
+ return "a kill switch (`%s`)" % name
1873
+ if _under(real, os.path.realpath(CONFIG_DIR)):
1874
+ return "tezgah's own configuration"
1875
+ for cache in {os.path.realpath(CACHE), os.path.realpath(fallback_cache())}:
1876
+ if any(_under(real, os.path.join(cache, d)) for d in CONTROL_CACHE):
1877
+ return "tezgah's evidence ledger or session state"
1878
+ if name in REPO_MARKS and root_for(parent):
1879
+ return "a repository opt-out mark (`%s`)" % name
1880
+ kind = _registration(real)
1881
+ if kind == "shared":
1882
+ return real
1883
+ # an install tree's own `.tezgah/` is the private workspace of a checkout
1884
+ # used as the install, not code the hooks run
1885
+ if kind or any(_under(real, tree) and not _under(real, os.path.join(tree, ".tezgah"))
1886
+ for tree in _install_trees(origin)):
1887
+ return "tezgah's hook wiring"
1888
+ if not name.endswith(".sample") and any(
1889
+ _under(real, os.path.join(top, ".git", "hooks")) for top in _tops(origin)):
1890
+ return "the repository's git hooks"
1891
+ if remove and ".husky" in real.split(os.sep):
1892
+ return "the repository's git hooks"
1893
+ opened = "%s.tezgah%splans%sopen%s" % ((os.sep,) * 4)
1894
+ if remove and opened in real + os.sep:
1895
+ # strictly under it is one named plan; the directory itself, or a glob
1896
+ # or brace that names its entries (`open/*`, `open/{a,b}`), is all
1897
+ rest = real.split(opened, 1)[1] if opened in real else ""
1898
+ plan = rest.split(os.sep, 1)[0]
1899
+ return OPEN_PLAN if plan and not GLOB_CHARS.search(plan) else HELD
1900
+ return None
1901
+
1902
+
1903
+ def control_target(path, cwd, remove=False, origin=None):
1904
+ """What `path` is in tezgah's control plane, or None.
1905
+
1906
+ `remove` is a delete, chmod or move: a directory that holds protected state
1907
+ (`_held`), a repository's husky hooks and the open plans are protected only
1908
+ from those. `origin` is the session's own directory (the checkout whose git
1909
+ hooks count), `cwd` the one a `cd` in the line moved to. The path is judged
1910
+ as spelled and with every link resolved. A shared registration file comes
1911
+ back as its own real path, for the caller to judge by key: an edit of a key
1912
+ that is not tezgah's is the user's business. An evidence label
1913
+ (CONTROL_EVIDENCE) comes back only when no spelling refuses."""
1914
+ origin = origin or cwd
1915
+ real = _real(path, cwd)
1916
+ noted = None
1917
+ for spelling in dict.fromkeys((real, _full(path, cwd))):
1918
+ label = _target_label(spelling, remove, origin)
1919
+ if _refusing(label):
1920
+ return label
1921
+ noted = noted or label
1922
+ # a delete through a link (`rm -rf link/`) takes what it points at: judge
1923
+ # the resolved target too
1924
+ if remove and any(_under(held, spelling) for held in _held(origin)
1925
+ for spelling in (real, _full(path, cwd))):
1926
+ return HELD
1927
+ return noted
1928
+
1929
+
1930
+ def _names_tezgah(value):
1931
+ return "tezgah" in json.dumps(value, sort_keys=True).lower()
1932
+
1933
+
1934
+ def _project(node):
1935
+ """`node` with every list cut to its items that name tezgah: a matcher group
1936
+ that holds tezgah's hook and the user's keeps the group's own fields and
1937
+ tezgah's entry, so the user's entry beside it is theirs to edit."""
1938
+ if isinstance(node, dict):
1939
+ return {k: _project(v) for k, v in node.items()}
1940
+ if isinstance(node, list):
1941
+ return [_project(v) for v in node if _names_tezgah(v)]
1942
+ return node
1943
+
1944
+
1945
+ def _owned(node, path=()):
1946
+ """The tezgah-owned entries of a parsed JSON document, position-free: the
1947
+ CLAUDE_OWNED top-level keys, any key naming tezgah, and each list item that
1948
+ names tezgah, projected (`_project`)."""
1949
+ def dump(value):
1950
+ return json.dumps(value, sort_keys=True)
1951
+ if isinstance(node, dict):
1952
+ out = []
1953
+ for key, value in node.items():
1954
+ if (not path and key in CLAUDE_OWNED) or "tezgah" in key.lower():
1955
+ out.append((path + (key,), dump(value)))
1956
+ else:
1957
+ out += _owned(value, path + (key,))
1958
+ return out
1959
+ if isinstance(node, list):
1960
+ return [(path, dump(_project(v))) for v in node if _names_tezgah(v)]
1961
+ return [(path, dump(node))] if "tezgah" in dump(node).lower() else []
1962
+
1963
+
1964
+ def _toml_owned(text):
1965
+ """The lines of a Codex config.toml that arm hooks: every `[hooks...]`
1966
+ section (the trust entries live under `[hooks.state]`), the `codex_hooks`
1967
+ feature and any line naming tezgah. ponytail: which trust entry is
1968
+ tezgah's is a hash codex computes, so all of them count."""
1969
+ out, keep = [], False
1970
+ for line in (text or "").splitlines():
1971
+ stripped = line.strip()
1972
+ if stripped.startswith("["):
1973
+ keep = stripped.lstrip("[").startswith("hooks")
1974
+ if keep or "tezgah" in line.lower() or re.match(r"\s*codex_hooks\s*=", line):
1975
+ out.append(stripped)
1976
+ return sorted(out)
1977
+
1978
+
1979
+ def _parsed(text):
1980
+ try:
1981
+ return json.loads(text)
1982
+ except (TypeError, ValueError):
1983
+ return None
1984
+
1985
+
1986
+ def _after_text(inp, current):
1987
+ """The file's text once this write tool call lands, or None for a shape
1988
+ this does not model (a patch): the caller then treats it as a change."""
1989
+ for key in ("content", "file_text", "contents", "text"):
1990
+ if isinstance(inp.get(key), str):
1991
+ return inp[key]
1992
+ edits = inp.get("edits") if isinstance(inp.get("edits"), list) else [inp]
1993
+ text = current
1994
+ for edit in edits:
1995
+ if not isinstance(edit, dict):
1996
+ return None
1997
+ old = next((edit[k] for k in ("old_string", "oldString", "old_str")
1998
+ if isinstance(edit.get(k), str)), None)
1999
+ new = next((edit[k] for k in ("new_string", "newString", "new_str")
2000
+ if isinstance(edit.get(k), str)), None)
2001
+ if old is None or new is None:
2002
+ return None
2003
+ text = text.replace(old, new, -1 if edit.get("replace_all") else 1)
2004
+ return text
2005
+
2006
+
2007
+ def _shared_change(real, inp):
2008
+ """True when this write changes a tezgah-owned entry of a shared file."""
2009
+ try:
2010
+ with open(real, encoding="utf-8") as fh:
2011
+ current = fh.read()
2012
+ except OSError:
2013
+ current = ""
2014
+ after = _after_text(inp, current)
2015
+ if after is None:
2016
+ return True
2017
+ if real.endswith(".toml"):
2018
+ return _toml_owned(current) != _toml_owned(after)
2019
+ return sorted(_owned(_parsed(current))) != sorted(_owned(_parsed(after)))
2020
+
2021
+
2022
+ def _shell_label(label):
2023
+ """A label for the deny text; a shared file is named with why a shell write
2024
+ to it is refused whole."""
2025
+ if not os.path.isabs(label):
2026
+ return label
2027
+ return ("%s, which holds tezgah's hook wiring. A shell write is not read "
2028
+ "key by key, so it is refused whole; an edit that leaves tezgah's "
2029
+ "entries as they are is not" % label)
2030
+
2031
+
2032
+ def _unquote(word):
2033
+ try:
2034
+ return "".join(shlex.split(word))
2035
+ except ValueError:
2036
+ return word.strip("'\"")
2037
+
2038
+
2039
+ # An escaped `"`, `'` or `\` outside single quotes, as one private-use character
2040
+ # each while shlex reads the line: shlex's non-posix mode knows no escape, so
2041
+ # `"a \"b\" c"` ended its word at the `\"`.
2042
+ _ESCAPES = {'\\"': "\ue000", "\\'": "\ue001", "\\\\": "\ue002"}
2043
+ _UNESCAPE = str.maketrans({v: k for k, v in _ESCAPES.items()})
2044
+
2045
+
2046
+ def _hide_escapes(line):
2047
+ out, quote, i = [], None, 0
2048
+ while i < len(line):
2049
+ pair = line[i:i + 2]
2050
+ if quote != "'" and pair in _ESCAPES:
2051
+ out.append(_ESCAPES[pair])
2052
+ i += 2
2053
+ continue
2054
+ if quote and line[i] == quote:
2055
+ quote = None
2056
+ elif not quote and line[i] in "'\"":
2057
+ quote = line[i]
2058
+ out.append(line[i])
2059
+ i += 1
2060
+ return "".join(out)
2061
+
2062
+
2063
+ def _blank_script_heredocs(text):
2064
+ """`text` with the body of each heredoc fed to a non-shell interpreter
2065
+ (`python3 - <<PY`) blanked even when its tag is unquoted, length and
2066
+ newlines kept: that body is the interpreter's script, data to this rule
2067
+ (SECURITY.md's interpreter residual). Only when bash would expand nothing
2068
+ in it - no `$` and no backtick in the body - and nothing on the operator's
2069
+ line before it opens a context (`HEREDOC_RISK`) or follows it on its line
2070
+ (`PLAIN_TAIL`, and no quote there); a body handed to a shell stays
2071
+ commands. The consumer is the last simple command before the operator,
2072
+ read by a plain split, so a quote or a backslash before the operator on
2073
+ its line, or a continued line above it, keeps the body visible: `bash -s
2074
+ \\| python3 <<EOF` and `bash -s "x | python3 " <<EOF` hand it to bash. Its
2075
+ word must be the bare interpreter name - no path, no env assignment - and
2076
+ nothing earlier in the call may define a function or alias of that name
2077
+ (`python3(){ bash; }`, `alias python3=bash`)."""
2078
+ out = list(text)
2079
+ for start, stop, _tag, _quoted, body, term, safe in _heredocs(text):
2080
+ line = text.rfind("\n", 0, start) + 1
2081
+ head = re.split(r"[;&|(]", text[line:start])[-1].split()
2082
+ name = re.escape(head[0]) if head else ""
2083
+ tail = PLAIN_TAIL.match(text, stop).end()
2084
+ end = text.find("\n", stop)
2085
+ if (safe or not term or not head
2086
+ or head[0] not in INTERPRETERS - SHELL_NAMES
2087
+ or re.search(r"[$`]", text[body:term[0]])
2088
+ or re.search(r"[\\'\"]", text[line:start])
2089
+ or re.search(r"['\"]", text[stop:end if end >= 0 else len(text)])
2090
+ or re.search(r"\balias\b|\bfunction\s+%s\b|\b%s\s*\(\s*\)"
2091
+ % (name, name), text[:start])
2092
+ or text[max(0, line - 2):line] == "\\\n"
2093
+ or HEREDOC_RISK.search(text, line, start)
2094
+ or tail < len(text) and text[tail] != "\n"):
2095
+ continue
2096
+ for k in range(body, term[0]):
2097
+ if out[k] != "\n":
2098
+ out[k] = " "
2099
+ return "".join(out)
2100
+
2101
+
2102
+ def _shell_words(command):
2103
+ """The line as (word, operator?) pairs with quotes kept on each word, so a
2104
+ quoted `>` stays data: shlex in non-posix mode, heredoc bodies blanked, an
2105
+ unquoted backtick read as `;`, a continued line joined and every command
2106
+ line ended by `;` - the way `_shell_segments` reads a line, minus dropping
2107
+ redirects. A newline inside quotes does not end a line (`_shell_lines`), so
2108
+ a multi-line `python3 -c '...'` is one word, not lines of commands. A line
2109
+ shlex cannot read is read roughly rather than dropped."""
2110
+ text = _unquoted_backticks(_blank_heredocs(_blank_script_heredocs(
2111
+ str(command or ""))).replace("\\\n", " "))
2112
+ out = []
2113
+ for line in map(_hide_escapes, _shell_lines(text)):
2114
+ try:
2115
+ lex = shlex.shlex(line, posix=False, punctuation_chars=";&|()<>")
2116
+ lex.whitespace_split = True
2117
+ lex.commenters = ""
2118
+ words = list(lex)
2119
+ except ValueError:
2120
+ words = re.findall(r"[;&|()<>]+|[^\s;&|()<>]+", line)
2121
+ for word in words:
2122
+ if word.startswith("#"):
2123
+ break # bash: a comment starts a word
2124
+ out.append((word.translate(_UNESCAPE), bool(SHELL_OPS.match(word))))
2125
+ out.append((";", True))
2126
+ return out
2127
+
2128
+
2129
+ # The wrappers `_program` reads past, by basename, with the options that take
2130
+ # the next word as their value; `timeout` also takes its duration.
2131
+ WRAPPER_VALUE_OPTS = {"sudo": ("-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "-U"),
2132
+ "env": ("-u", "--unset", "-C", "--chdir"),
2133
+ "nice": ("-n", "--adjustment"), "nohup": (), "exec": ("-a",),
2134
+ "time": ("-f", "-o"), "command": (),
2135
+ "timeout": ("-s", "--signal", "-k", "--kill-after"),
2136
+ "stdbuf": ("-i", "-o", "-e")}
2137
+ # An interpreter's options that take the next word as their value, and the ones
2138
+ # that make the program its own text rather than a script (`python3 -c`, `-m`).
2139
+ INTERPRETER_VALUE_OPTS = ("-X", "-W", "-o", "+o", "-O")
2140
+
2141
+
2142
+ def _program(words):
2143
+ """(program basename without `.py`, its arguments) for one simple command,
2144
+ past compound keywords, env assignments, the wrappers (WRAPPER_VALUE_OPTS,
2145
+ by basename, with their options) and an interpreter's options before the
2146
+ script it runs."""
2147
+ i = 0
2148
+ while i < len(words):
2149
+ word = words[i]
2150
+ wrapper = os.path.basename(word)
2151
+ if ENV_WORD.match(word) or word in SHELL_KEYWORDS:
2152
+ i += 1
2153
+ continue
2154
+ if wrapper not in WRAPPER_VALUE_OPTS:
2155
+ break
2156
+ i += 1
2157
+ while i < len(words) and words[i].startswith("-"):
2158
+ i += 2 if words[i] in WRAPPER_VALUE_OPTS[wrapper] else 1
2159
+ if wrapper == "timeout" and i < len(words):
2160
+ i += 1 # the duration
2161
+ words = words[i:]
2162
+ if words and words[0] == "[":
2163
+ # the test command: its operands are not a program, and the command
2164
+ # after the `]` is (`if [ -d x ]; then touch y; fi`)
2165
+ close = words.index("]") if "]" in words else len(words)
2166
+ words = words[close + 1:]
2167
+ if not words:
2168
+ return "", []
2169
+ name = os.path.basename(words[0])
2170
+ if name in INTERPRETERS or re.fullmatch(r"python[\d.]+", name):
2171
+ j = 1
2172
+ while j < len(words) and words[j].startswith(("-", "+")) and words[j] != "-":
2173
+ flag = words[j]
2174
+ if re.fullmatch(r"-[A-Za-z]*[cm]" if name.startswith("python")
2175
+ else r"-[A-Za-z]*c[A-Za-z]*", flag):
2176
+ j = len(words) + 1 # the program is its own text: no script word
2177
+ break
2178
+ j += 2 if flag in INTERPRETER_VALUE_OPTS else 1
2179
+ if j < len(words):
2180
+ words = words[j:]
2181
+ if not words:
2182
+ return "", []
2183
+ name = os.path.basename(words[0])
2184
+ return (name[:-3] if name.endswith(".py") else name), words[1:]
2185
+
2186
+
2187
+ def _option_values(args, opts):
2188
+ """The values `args` gives the options in `opts`: `-o v`, `--output=v`, a
2189
+ short option closing a cluster (`-xzf v`) and one with its value attached
2190
+ (`-ov`)."""
2191
+ out = []
2192
+ for i, arg in enumerate(args):
2193
+ for opt in opts:
2194
+ if opt.startswith("--") and arg.startswith(opt + "="):
2195
+ out.append(arg[len(opt) + 1:])
2196
+ elif (arg == opt or not opt.startswith("--") and re.fullmatch(
2197
+ r"-[A-Za-z]*" + re.escape(opt[1:]), arg)) and i + 1 < len(args):
2198
+ out.append(args[i + 1])
2199
+ elif not opt.startswith("--") and arg.startswith(opt) and len(arg) > 2:
2200
+ out.append(arg[2:])
2201
+ return out
2202
+
2203
+
2204
+ def _chmod_removes(args):
2205
+ """True when a chmod mode can take a permission away (`-x`, `a=r`, `644`)."""
2206
+ mode = next((a for a in args if not a.startswith("-") or re.fullmatch(
2207
+ r"-[rwxXst]+", a)), "")
2208
+ if re.fullmatch(r"[0-7]{3,4}", mode):
2209
+ return not int(mode[-3]) & 1
2210
+ return "-" in mode or "=" in mode and "x" not in mode.split("=", 1)[1]
2211
+
2212
+
2213
+ def _git_change(args, cwd, origin):
2214
+ """The control-plane label for a `git` command's own change, or None: a
2215
+ forced add of a `.tezgah/` path, an `rm`/`mv` of protected state that is not
2216
+ `--cached` (plan-sync records a move with `git -C .tezgah rm -q --cached`),
2217
+ or a `core.hooksPath` that is set or unset rather than read."""
2218
+ i, where = 0, cwd
2219
+ while i < len(args) and args[i].startswith("-"):
2220
+ if args[i] == "-C" and i + 1 < len(args):
2221
+ where = _real(args[i + 1], where)
2222
+ i += 2 if args[i] in GIT_VALUE_OPTS else 1
2223
+ if i >= len(args):
2224
+ return None
2225
+ sub, rest = args[i], args[i + 1:]
2226
+ paths = [a for a in rest if not a.startswith("-")]
2227
+ if sub == "add" and any(a == "--force" or re.match(r"-[A-Za-z]*f", a)
2228
+ for a in rest):
2229
+ if any(".tezgah" in a.replace("\\", "/").split("/") for a in paths):
2230
+ return FORCED_ADD
2231
+ if sub in ("rm", "mv") and "--cached" not in rest:
2232
+ noted = None
2233
+ for path in paths:
2234
+ label = control_target(path, where, remove=True, origin=origin)
2235
+ if _refusing(label):
2236
+ return _shell_label(label)
2237
+ noted = noted or label
2238
+ return noted
2239
+ if sub == "config" and HOOKS_KEY in (a.lower() for a in rest):
2240
+ head = rest[:rest.index("--")] if "--" in rest else rest
2241
+ positional = [a for a in head if not a.startswith("-")]
2242
+ if any(a.startswith(("--get", "--list")) or a == "-l" for a in head) \
2243
+ or positional[:1] in (["get"], ["list"]):
2244
+ return None
2245
+ # the session's own checkout, or every repository at once
2246
+ mine = any(a in ("--global", "--system") for a in head) or any(
2247
+ _under(os.path.realpath(where), top) for top in _tops(origin))
2248
+ if mine and (len(positional) > 1 or any(a.startswith("--unset") for a in head)):
2249
+ return "the repository's git hooks"
2250
+ return None
2251
+
2252
+
2253
+ def _host_hook(words, cwd):
2254
+ """True when the `hook.py` this command runs sits under `hosts/<host>/`
2255
+ (HOOK_HOSTS), as written or with its links resolved."""
2256
+ word = next((w for w in words if os.path.basename(w) == "hook.py"), "")
2257
+ written = os.path.join(cwd or os.sep, os.path.expanduser(word))
2258
+ return any(os.path.basename(os.path.dirname(p)) in HOOK_HOSTS
2259
+ and os.path.basename(os.path.dirname(os.path.dirname(p))) == "hosts"
2260
+ for p in (os.path.normpath(written), _full(word, cwd)))
2261
+
2262
+
2263
+ def _command_change(words, cwd, depth, origin):
2264
+ """The control-plane label one simple command changes, or None."""
2265
+ program, args = _program(words)
2266
+ if program == "eval" and depth < 3:
2267
+ return shell_control(" ".join(args), cwd, depth + 1, origin)
2268
+ if program in SHELL_NAMES and depth < 3:
2269
+ # `-c`, or any flag cluster holding it (`-lc`, `-ec`); a `--` after it
2270
+ # ends the options and the script is the word after that
2271
+ at = next((k for k, a in enumerate(args)
2272
+ if re.fullmatch(r"-[A-Za-z]*c[A-Za-z]*", a)), None)
2273
+ script = args[at + 1:] if at is not None else []
2274
+ script = script[1:] if script[:1] == ["--"] else script
2275
+ if script:
2276
+ return shell_control(script[0], cwd, depth + 1, origin)
2277
+ if program in HOOK_ENTRIES or (program == "hook" and _host_hook(words, cwd)):
2278
+ return "tezgah's hook state through one of its hook entries"
2279
+ if program in CONTROL_CLIS:
2280
+ verbs = CONTROL_CLIS[program]
2281
+ positional = [a for a in args if not a.startswith("-")]
2282
+ if verbs is None or (positional and (verbs == "" or
2283
+ positional[0].lower() in verbs)):
2284
+ return "tezgah's control state through one of its own CLIs"
2285
+ return None
2286
+ if program == "git":
2287
+ return _git_change(args, cwd, origin)
2288
+ checks = [(v, False) for v in _option_values(args, OPTION_TARGETS.get(program, ()))]
2289
+ if program == "tar":
2290
+ if any(a in ("--extract", "--get") or re.fullmatch(r"-?[A-Za-z]*x[A-Za-z]*", a)
2291
+ for a in args[:1] + [a for a in args if a.startswith("-")]):
2292
+ checks += [(v, False) for v in _option_values(args, ("-C", "--directory"))]
2293
+ checks += [(v, False) for v in _option_values(args, ("-f", "--file"))]
2294
+ if program in CONTROL_PROGRAMS and not (program in ("sed", "perl") and not any(
2295
+ re.match(r"-[A-Za-z]*i", a) for a in args)):
2296
+ remove = program in CONTROL_REMOVERS and (
2297
+ program != "chmod" or _chmod_removes(args))
2298
+ if program == "dd":
2299
+ checks += [(a[3:], False) for a in args if a.startswith("of=")]
2300
+ else:
2301
+ targets = [a for a in args if not a.startswith("-")]
2302
+ if program in CONTROL_COPIERS and targets:
2303
+ checks += [(t, remove) for t in targets[:-1]] if program == "mv" else []
2304
+ # `rsync --delete` empties the destination of what the source lacks
2305
+ targets, remove = targets[-1:], program == "rsync" and any(
2306
+ a.startswith("--delete") for a in args)
2307
+ checks += [(t, remove) for t in targets]
2308
+ noted = None
2309
+ for target, remove in checks:
2310
+ label = control_target(target, cwd, remove=remove, origin=origin)
2311
+ if _refusing(label):
2312
+ return _shell_label(label)
2313
+ noted = noted or label
2314
+ return noted
2315
+
2316
+
2317
+ def shell_control(command, cwd, depth=0, origin=None):
2318
+ """The control-plane label a shell command changes, or None.
2319
+
2320
+ CONTROL_WRITE in the plan's words, read rather than matched, one simple
2321
+ command at a time and in order: a `cd` (or `pushd`) moves the directory the
2322
+ words after it resolve against; a redirect's target, the arguments of a
2323
+ program that changes a path (CONTROL_PROGRAMS, OPTION_TARGETS, `tar`) and a
2324
+ CLI that changes tezgah's state (CONTROL_CLIS) are judged, each expanded the
2325
+ way the shell would (`~`, `$HOME`, the XDG variables). Compound keywords
2326
+ (`if`, `do`, `!`) are read past, and `eval` and `bash -c` (any flag cluster
2327
+ holding `c`) are opened. A quoted word is a word and not a command, so a
2328
+ grep or a commit message that names a switch is not one. SHELL_WRITE is not
2329
+ widened for this: it feeds the task phase rule and the JS mirror.
2330
+ ponytail: an interpreter (`python3 -c "open(...)"`), `find -delete`,
2331
+ `xargs`, a glob or brace in a directory, a link made earlier in the same
2332
+ line and a path assembled at run time are not read; SECURITY.md names them
2333
+ as the residual routes."""
2334
+ origin = origin or cwd
2335
+ where, words, pending, noted = cwd, [], None, None
2336
+ for word, is_op in _shell_words(command):
2337
+ if pending:
2338
+ judged, pending = pending, None
2339
+ if not is_op:
2340
+ target = _unquote(word)
2341
+ if judged == ">" and not (target.isdigit() or target == "-"):
2342
+ label = control_target(target, where, origin=origin)
2343
+ if _refusing(label):
2344
+ return _shell_label(label)
2345
+ noted = noted or label
2346
+ continue
2347
+ if is_op and ("<" in word or ">" in word):
2348
+ if words and words[-1].isdigit():
2349
+ words.pop() # the descriptor before `2>`
2350
+ pending = ">" if ">" in word else "<"
2351
+ continue
2352
+ if not is_op:
2353
+ words.append(word)
2354
+ continue
2355
+ plain = [_unquote(w) for w in words]
2356
+ words = []
2357
+ # `[`/`]` are left to `_program: the test command is skipped there, so
2358
+ # the command after it in an `if` line is read
2359
+ while plain and plain[0] in SHELL_KEYWORDS:
2360
+ plain = plain[1:]
2361
+ if plain and plain[0] in ("cd", "pushd"):
2362
+ dest = [a for a in plain[1:] if not a.startswith("-") or a == "-"]
2363
+ if not dest or dest[0] != "-":
2364
+ where = _full(dest[0] if dest else "~", where)
2365
+ continue
2366
+ label = plain and _command_change(plain, where, depth, origin)
2367
+ if _refusing(label):
2368
+ return label
2369
+ noted = noted or label or None
2370
+ # bash runs the `$( )` and backtick substitutions of an unquoted-tag
2371
+ # heredoc body, whatever the consumer and whatever quotes the body holds,
2372
+ # and the line reader above sees them as data (blanked or quoted)
2373
+ text = str(command or "")
2374
+ if "<<" in text and depth < 3:
2375
+ from tezgah_context import _substitutions
2376
+ for h in _heredocs(text):
2377
+ if h[3]:
2378
+ continue
2379
+ for a, b in _substitutions(text, h[4], h[5][0] if h[5] else len(text)):
2380
+ inner = text[a + (2 if text[a] == "$" else 1):b - 1]
2381
+ for base in dict.fromkeys((cwd, where)):
2382
+ label = shell_control(inner, base, depth + 1, origin)
2383
+ if _refusing(label):
2384
+ return label
2385
+ noted = noted or label
2386
+ return noted
2387
+
2388
+
2389
+ def control_reason(t, inp, cwd):
2390
+ """The control-plane label this call changes, or None (see CONTROL_DENY).
2391
+ A CONTROL_EVIDENCE label comes back only when nothing in the call refuses."""
2392
+ if t in BASH_TOOLS:
2393
+ return shell_control(inp.get("command") or inp.get("cmd"), cwd)
2394
+ deletes = set(PATCH_DELETE.findall(str(inp.get("patch") or "")))
2395
+ noted = None
2396
+ for path in write_paths(inp):
2397
+ label = control_target(path, cwd, remove=path in deletes)
2398
+ if label and not os.path.isabs(label):
2399
+ if _refusing(label):
2400
+ return label
2401
+ noted = noted or label
2402
+ elif label and _shared_change(label, inp):
2403
+ return "tezgah's hook wiring"
2404
+ return noted
2405
+
2406
+
2407
+ def _deny(session_id, rule, reason, tool=None, inp=None, workspace=None,
2408
+ extra=None):
2409
+ """Record a refusal before returning it: a deny nobody counts is a rule
2410
+ whose effect can never be argued about (hooks/tezgah_integrity.counters).
2411
+
2412
+ The row carries the refused call's id and workspace, so the ledger says
2413
+ which action was stopped and where - the same two facts a PostToolUse row
2414
+ carries. `extra` appends a fact the row must keep beyond the 80 characters
2415
+ of the reason it truncates."""
2416
+ detail = "%s: %s" % (rule, cut(reason, 80))
2417
+ if extra:
2418
+ detail = "%s; %s" % (detail, extra)
2419
+ note(session_id, "deny", detail, id=call_id(tool, inp), workspace=workspace)
2420
+ return reason
2421
+
2422
+
2423
+ def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
2424
+ """A deny reason for this call, or None to let it pass.
2425
+
2426
+ `record=False` is the dry run `tezgah-gate check` and the MCP
2427
+ `tezgah_gate_check` promise ("without running it"): the same answer, with
2428
+ no deny/drift/nudge row, no nudge mark and no snapshot. A dry run against a
2429
+ live session id wrote refusal rows into that session's counters (audit
2430
+ CHAT-07 / L-14b).
2431
+
2432
+ `agent` is the host's subagent id when the call came from one (Claude's
2433
+ `agent_id`): the repeat ceilings count that agent's own attempts, so one
2434
+ sibling's failures are not spent as another's."""
2435
+ bind_session(session_id) # the switch latch: off() below answers for this session
2436
+ if not record:
2437
+ return _dry_decision(tool, inp, cwd, session_id, agent)
2438
+ if off("pretooluse-off"):
2439
+ return None
2440
+ base = root_for(cwd)
2441
+ if not base:
2442
+ return None
2443
+ t = str(tool or "").lower()
2444
+ # A write or shell call's first row (tezgah_integrity.BEGAN_KIND), written
2445
+ # before any rule runs: a host that kills a slow gate still runs the call,
2446
+ # and a pass with no `began` reads as an orphan (`_pair`). The PostToolUse
2447
+ # hook writes the second row; a refusal below answers it with its `deny`.
2448
+ # Through `note`, so the dry run writes none. `check` is decided here, from
2449
+ # the whole command: the stored detail is cut at DETAIL_MAX.
2450
+ if t in WRITE_TOOLS or t in BASH_TOOLS:
2451
+ digest = call_id(tool, inp)
2452
+ if digest:
2453
+ command = str(inp.get("command") or inp.get("cmd") or "")
2454
+ note(session_id, BEGAN_KIND, command or (write_paths(inp) or [""])[0],
2455
+ id=digest, tool=str(tool or "").strip() or None, workspace=base,
2456
+ check=1 if t in BASH_TOOLS and verify_command(command) else None)
2457
+ # Claude's Task names the agent `subagent_type`; omp's task names it `agent`,
2458
+ # on the call (flat shape) or on each `tasks[]` item (batch shape).
2459
+ subs = [inp.get("subagent_type"),
2460
+ (inp.get("args") or {}).get("subagent_type"), inp.get("agent")]
2461
+ subs += [item.get("agent") for item in inp.get("tasks") or ()
2462
+ if isinstance(item, dict)]
2463
+ # The body a shell call writes, read once: the three write-tool-only rules
2464
+ # below reach it through the shell (see the section above). None for every
2465
+ # tool that is not a shell, and for a shell line that writes no body.
2466
+ shell_body = (shell_write_body(inp.get("command"), cwd)
2467
+ if t in BASH_TOOLS else None)
2468
+ # Control plane, first after the root check: every rule below is held by a
2469
+ # file this one protects (control_reason). No switch but `pretooluse-off`
2470
+ # removes it, and that one is among the files. A CONTROL_EVIDENCE target is
2471
+ # not refused: the call goes on to the rules below, and a call they let
2472
+ # through leaves a `disarm` row beside its `began` row.
2473
+ evidence = None
2474
+ if t in WRITE_TOOLS + BASH_TOOLS:
2475
+ what = control_reason(t, inp, cwd)
2476
+ if _refusing(what):
2477
+ return _deny(session_id, "control", CONTROL_DENY % what, tool, inp,
2478
+ base)
2479
+ evidence = what
2480
+ if t in ("agent", "task", "subagent") and any(explored(s) for s in subs):
2481
+ return _deny(session_id, "explorer", EXPLORE_DENY, tool, inp, base)
2482
+ # anti-shortcut: a check neutered so it cannot fail, or a test disabled so a
2483
+ # failure disappears. This is the mechanical half of the integrity rule; the
2484
+ # reply-level half is the Stop hook in hooks/projects-stop.py (Claude),
2485
+ # hosts/codex/hook.py, hosts/cursor/hook.py and hosts/omp/hook.py. `verify-off`
2486
+ # removes that rule, so it drops this half too; `pretooluse-off` above still
2487
+ # drops the whole gate, and attribution/explore are other rules and stay
2488
+ # armed. The shell half of the write rule reads the body a heredoc writes,
2489
+ # which is the route E7c measured once the write tools were refused.
2490
+ if not off("verify-off"):
2491
+ if t in BASH_TOOLS:
2492
+ reason = shortcut_command(inp.get("command"))
2493
+ if reason:
2494
+ return _deny(session_id, "shortcut", reason, tool, inp, base)
2495
+ reason = piped_check and piped_check(inp.get("command"))
2496
+ if reason:
2497
+ return _deny(session_id, "piped", reason, tool, inp, base)
2498
+ if shell_body:
2499
+ reason = shortcut_edit(shell_body)
2500
+ if reason:
2501
+ return _deny(session_id, "shortcut", reason, tool, inp, base)
2502
+ if t in WRITE_TOOLS:
2503
+ reason = shortcut_edit(inp)
2504
+ if reason:
2505
+ return _deny(session_id, "shortcut", reason, tool, inp, base)
2506
+ if t in BASH_TOOLS and attribution(inp.get("command")):
2507
+ return _deny(session_id, "attribution", ATTRIB_DENY, tool, inp, base)
2508
+ if shell_body and attribution_edit(shell_body):
2509
+ return _deny(session_id, "attribution", ATTRIB_DENY, tool, inp, base)
2510
+ if t in WRITE_TOOLS and attribution_edit(inp):
2511
+ return _deny(session_id, "attribution", ATTRIB_DENY, tool, inp, base)
2512
+ # Language: the identifiers this command would create. Beside the
2513
+ # attribution rule because both read the command's own text for a form it
2514
+ # carries, and both are about what the artifact will say for good. The rule
2515
+ # has its own switch, and the whole-gate one above drops it with everything
2516
+ # else: a heuristic that refuses a word the user chose has to be one the
2517
+ # user can take off (see tezgah_lang's docstring for what it cannot decide).
2518
+ if not off("lang-off") and t in BASH_TOOLS:
2519
+ reason = lang_reason(inp.get("command"), cwd)
2520
+ if reason:
2521
+ return _deny(session_id, "lang", reason, tool, inp, base)
2522
+ # Concurrent write: another session wrote one of this call's files inside
2523
+ # RACE_WINDOW_MIN (the constant carries why it refuses). Ahead of the repeat
2524
+ # guards, so a colliding write is counted as this rule and not as a repeat of
2525
+ # one. A shell command that writes a file through a redirect or `tee` is a
2526
+ # write here too: the rules below read its target from `write_paths`, the
2527
+ # reader `capture` and the PostToolUse row already share - unless all it
2528
+ # writes is its own scratch (`scratch_target`), such as a `> /tmp/check.log`.
2529
+ writes = t in WRITE_TOOLS or (t in BASH_TOOLS and any(
2530
+ not scratch_target(p, cwd) for p in write_paths(inp)))
2531
+ if writes and RACE_REFUSE:
2532
+ reason = race_reason(inp, session_id, cwd)
2533
+ if reason:
2534
+ return _deny(session_id, "race", reason, tool, inp, base)
2535
+ # Task scope: the user's own record for this work - its phase and the files
2536
+ # its allowlist names (see task_reason). The
2537
+ # record itself is refused first of all, and a shell that would change it
2538
+ # through the CLI is refused with it: a boundary the agent can move is not a
2539
+ # boundary, and both routes to moving this one were measured open (E7).
2540
+ if not off("task-off"):
2541
+ # The record is read once and handed to every rule below: each read lists
2542
+ # `.tezgah/plans/open/` and parses a plan, and this path is every write of
2543
+ # every session. Reads and non-write tools pay nothing.
2544
+ task = _UNRESOLVED
2545
+ if tezgah_task is not None and t in WRITE_TOOLS + BASH_TOOLS:
2546
+ task = tezgah_task.active(cwd, base)
2547
+ if writes:
2548
+ reason = task_record_reason(inp, cwd, base, task)
2549
+ if reason:
2550
+ return _deny(session_id, "task", reason, tool, inp, base)
2551
+ if t in BASH_TOOLS and TASK_CHANGE.search(
2552
+ mask(str(inp.get("command") or ""))):
2553
+ return _deny(session_id, "task", TASK_UNLOCK_DENY, tool, inp, base)
2554
+ if t in BASH_TOOLS:
2555
+ reason = task_shell_reason(inp, cwd, base, task)
2556
+ if reason:
2557
+ return _deny(session_id, "task", reason, tool, inp, base)
2558
+ if writes:
2559
+ reason = task_reason(inp, cwd, base, task)
2560
+ if reason:
2561
+ return _deny(session_id, "task", reason, tool, inp, base)
2562
+ # The checkpoint: the same phase, the state it starts from. After the
2563
+ # scope rule, because a path outside the allowlist is the more specific
2564
+ # fault in the same call (see checkpoint_reason for why the rule is here
2565
+ # at all and not only at the phase move).
2566
+ if t in WRITE_TOOLS + BASH_TOOLS:
2567
+ reason = checkpoint_reason(inp, cwd, base, task)
2568
+ if reason:
2569
+ return _deny(session_id, "task", reason, tool, inp, base)
2570
+ # Workspace: tezgah state belongs under .tezgah/, never a root plans/,
2571
+ # research/ or analysis/ the project does not own (see workspace_reason).
2572
+ if not off("workspace-off") and t in WRITE_TOOLS + BASH_TOOLS:
2573
+ reason = workspace_reason(inp, cwd, base)
2574
+ if reason:
2575
+ return _deny(session_id, "workspace", reason, tool, inp, base)
2576
+ # MCP: a server's effect tool lands text the way a write tool or a `gh`
2577
+ # write does, so its payload meets the same content rules. The verb class
2578
+ # (tezgah_integrity.MCP_VERBS) only picks which rules read it - it never
2579
+ # refuses or asks by itself - and the payload is read through the bounded
2580
+ # walk (mcp_text). The credential half reads vendor token shapes only
2581
+ # (MCP_TOKEN), never the shell's name=value shape.
2582
+ verb = mcp_class(tool)
2583
+ if verb in ("write", "publish"):
2584
+ text = mcp_text(inp)
2585
+ if verb == "write" and not off("verify-off"):
2586
+ reason = shortcut_edit({"file_path": (write_paths(inp) or [""])[0],
2587
+ "content": text})
2588
+ if reason:
2589
+ return _deny(session_id, "shortcut", reason, tool, inp, base)
2590
+ if ATTRIB_LINE.search(text):
2591
+ return _deny(session_id, "attribution", ATTRIB_DENY, tool, inp, base)
2592
+ if MCP_TOKEN.search(text):
2593
+ return _deny(session_id, "secret", MCP_SECRET_DENY, tool, inp, base)
2594
+ # A credential on its way into a file. No escape hatch: the deny text
2595
+ # names the rephrase (a name, a length, a fingerprint), so the write can
2596
+ # be replaced rather than repeated. The shell's own text is read for the
2597
+ # name=value and bearer shapes next to a sink; the text a write lands - a
2598
+ # write tool's content, or the body a heredoc writes, which mask() blanks -
2599
+ # for the prefixed token families (see SECRET_EDIT_DENY for why the two
2600
+ # differ), so a Write and a heredoc landing the same text agree.
2601
+ if t in BASH_TOOLS:
2602
+ reason = secret_command(inp.get("command"))
2603
+ if reason:
2604
+ return _deny(session_id, "secret", reason, tool, inp, base)
2605
+ body = inp if t in WRITE_TOOLS else shell_body
2606
+ if body and secret_edit(body, cwd):
2607
+ return _deny(session_id, "secret", SECRET_EDIT_DENY, tool, inp, base)
2608
+ # Plan required: the turn's third product file while the checkout is on
2609
+ # main/master (see plan_reason). Below the task, workspace and secret rules,
2610
+ # because each names a more specific fault in the same write, and above the
2611
+ # repeat guards, because it is the call that is refused and not a repeat of
2612
+ # one. Standing: only changing the tree's state changes it.
2613
+ if t in WRITE_TOOLS + BASH_TOOLS:
2614
+ reason = plan_reason(inp, cwd, session_id)
2615
+ if reason:
2616
+ return _deny(session_id, "plan", reason, tool, inp, base)
2617
+ # Ordering: a commit asserted over a check that just failed (see
2618
+ # commit_order_reason). An argument-shaped rule, so it sits with them and
2619
+ # above the repeat guards; it rides `verify-off`, the switch that governs the
2620
+ # rest of the integrity rule, because this is that rule's claim read at the
2621
+ # one moment it lands in the repository's history.
2622
+ if not off("verify-off") and t in BASH_TOOLS:
2623
+ reason = commit_order_reason(inp.get("command"), session_id)
2624
+ if reason:
2625
+ return _deny(session_id, "order", reason, tool, inp, base)
2626
+ # Repeat guards, under the same kill switch as the other integrity denials
2627
+ # and after every argument-shaped rule: a call another rule would have
2628
+ # refused has to be counted as that rule, not as a repeat. `loop` is the
2629
+ # failure-scoped half (identical attempts that failed, per user turn), `retry`
2630
+ # the session-wide ceiling above it (every attempt of the call, whatever it
2631
+ # returned). Both read the ledger tail, which is the only file I/O this path
2632
+ # is allowed.
2633
+ if not off("verify-off") and t in BASH_TOOLS + WRITE_TOOLS:
2634
+ reason = loop_reason(tool, inp, session_id, agent)
2635
+ if reason:
2636
+ return _deny(session_id, "loop", reason, tool, inp, base)
2637
+ reason = retry_reason(tool, inp, session_id, agent)
2638
+ if reason:
2639
+ return _deny(session_id, "retry", reason, tool, inp, base)
2640
+ if symbol := searched_identifier(tool, inp):
2641
+ slug = index_slug(cwd, base)
2642
+ if slug and first_nudge(session_id):
2643
+ note(session_id, "nudge", slug, id=call_id(tool, inp),
2644
+ workspace=base)
2645
+ return nudge_reason(symbol)
2646
+ # Constraint drift, last: a re-statement yields to every refusal above (a
2647
+ # refusal's reason is the same channel), and `verify-off` does not drop it -
2648
+ # the rules it re-states are not the verify rule. The switch that governs it
2649
+ # is the per-turn reminder's own (checked in drift_reason), because this is
2650
+ # that reminder's mid-turn half.
2651
+ reason = drift_reason(tool, inp, cwd, session_id)
2652
+ if reason:
2653
+ return _deny(session_id, "drift", reason, tool, inp, base)
2654
+ # Lesson lineage: a ledger write about to land in a turn that read untrusted
2655
+ # text leaves its rows and is let through (see lesson_taint; ADR 010). After
2656
+ # every refusal, so a refused write that will be re-issued is counted once;
2657
+ # a failure costs the rows, never the write.
2658
+ if t in WRITE_TOOLS + BASH_TOOLS:
2659
+ try:
2660
+ lesson_taint(tool, inp, cwd, session_id, agent, shell_body)
2661
+ except Exception:
2662
+ pass
2663
+ # Nothing refused this call, so a write is about to land: keep the bytes it
2664
+ # is about to change, which is what bin/tezgah-rollback restores by hand.
2665
+ # Nothing automatic undoes work here - see tezgah_snapshot's own note on why.
2666
+ # A refused write changes no file, so it is never captured, and this is the
2667
+ # only place the gate writes anything the deny path does not. capture returns
2668
+ # None rather than raising, but no host wraps decision(), so the outer try is
2669
+ # cheap insurance in the one path where a failure would block an edit.
2670
+ #
2671
+ # A shell call that writes a file is the same call for this purpose (see
2672
+ # write_paths): without the pre-state, the after-state alone cannot tell a
2673
+ # write that landed from one that was a no-op, and the freshness rule would
2674
+ # count every redirect as a change. `capture` reads a write tool's own path
2675
+ # field and takes no shell tool name, so the target goes in the shape it
2676
+ # reads - SHELL_AS_WRITE - and the row it writes carries no tool name, so
2677
+ # nothing on the ledger claims a write tool ran.
2678
+ #
2679
+ # A control-plane change the rules let through (CONTROL_EVIDENCE) leaves
2680
+ # its `disarm` row here, on the allow path. The call's `began` row is
2681
+ # written before the rules (see the top of this function).
2682
+ if evidence and (t in WRITE_TOOLS or t in BASH_TOOLS):
2683
+ note(session_id, "disarm", "control: %s" % evidence,
2684
+ id=call_id(tool, inp), workspace=base)
2685
+ if capture and t in WRITE_TOOLS:
2686
+ try:
2687
+ capture(tool, inp, cwd, session_id)
2688
+ except Exception:
2689
+ pass
2690
+ elif capture and t in BASH_TOOLS:
2691
+ try:
2692
+ for path in write_paths(inp):
2693
+ capture(SHELL_AS_WRITE, {"file_path": path}, cwd, session_id)
2694
+ except Exception:
2695
+ pass
2696
+ return None
2697
+
2698
+
2699
+ def _dry_decision(tool, inp, cwd, session_id, agent=None):
2700
+ """`decision` with every write it makes swapped out for the call.
2701
+
2702
+ The writers are module names `decision` reaches through (`note` for the
2703
+ deny, drift, nudge and lesson_tainted rows, `write_taint` for the lesson
2704
+ taint index, `first_nudge` for the once-per-session mark, `capture` for the
2705
+ snapshot), so one swap covers every rule and a new rule
2706
+ that records through them is dry here without being told. Safe because
2707
+ the dry run is its own process (bin/tezgah-gate check): nothing else in it
2708
+ reads these names while they are swapped, and the finally puts them back.
2709
+ The nudge is still answered as the live path would answer it - spent when
2710
+ its mark exists - only the mark is not written."""
2711
+ g = globals()
2712
+ saved = {name: g[name] for name in ("note", "write_taint", "first_nudge",
2713
+ "capture")}
2714
+
2715
+ g.update(note=lambda *a, **k: None, write_taint=lambda *a, **k: None,
2716
+ capture=None,
2717
+ first_nudge=lambda sid: not os.path.exists(nudge_mark(sid)))
2718
+ try:
2719
+ return decision(tool, inp, cwd, session_id, agent=agent)
2720
+ finally:
2721
+ g.update(saved)
2722
+
2723
+
2724
+
2725
+ # Every rule slug `decision` records a refusal under (`_deny`'s second argument),
2726
+ # with the kill switch that disarms it beside `pretooluse-off`, which disarms
2727
+ # them all; None means only that one. A lesson the ledger retires with
2728
+ # `|| enforced_by: <slug>` (tezgah_lessons.lines) leaves the injected
2729
+ # block only while its rule is armed, so `piped` brings its lesson back under
2730
+ # `verify-off`. `tests/test_lessons.py` holds this map to the `_deny` literals.
2731
+ DENY_RULES = {"control": None, "explorer": None, "shortcut": "verify-off",
2732
+ "piped": "verify-off",
2733
+ "attribution": None, "lang": "lang-off", "race": None,
2734
+ "task": "task-off", "workspace": "workspace-off", "secret": None,
2735
+ "plan": None, "order": "verify-off", "loop": "verify-off",
2736
+ "retry": "verify-off", "drift": "reminder-off"}