@r1z4x/tezgah 1.9.2 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (445) hide show
  1. package/CHANGELOG.md +415 -1
  2. package/MANIFEST +404 -144
  3. package/README.de.md +1 -1
  4. package/README.es.md +1 -1
  5. package/README.fr.md +1 -1
  6. package/README.md +5 -4
  7. package/README.tr.md +1 -1
  8. package/bin/codegen +5 -2
  9. package/bin/tezgah-design +288 -1
  10. package/bin/tezgah-docs +1 -1
  11. package/bin/tezgah-doctor +39 -2
  12. package/bin/tezgah-import-ai-research +26 -12
  13. package/bin/tezgah-ocr-review +296 -0
  14. package/bin/tezgah-research +255 -44
  15. package/bin/tezgah-review-quote +185 -0
  16. package/bin/tezgah-setup +1365 -55
  17. package/bin/tezgah-skill +1 -1
  18. package/bin/tezgah-status +201 -3
  19. package/bin/tezgah-task +258 -20
  20. package/commands/adhd.md +2 -2
  21. package/commands/ponytail.md +4 -4
  22. package/docs/architecture.md +2 -2
  23. package/docs/citations-baseline.json +3 -3
  24. package/docs/clarity-baseline.json +11 -5
  25. package/docs/contract.md +91 -43
  26. package/docs/environment.md +5 -2
  27. package/docs/evidence.md +109 -17
  28. package/docs/feature-audit.md +2 -2
  29. package/docs/gate.md +60 -33
  30. package/docs/glossary.md +6 -3
  31. package/docs/hosts.md +313 -17
  32. package/docs/index.json +34 -8
  33. package/docs/judge.md +4 -2
  34. package/docs/layers.md +4 -4
  35. package/docs/models.md +37 -9
  36. package/docs/operations.md +141 -5
  37. package/docs/research.md +815 -8
  38. package/docs/skills.md +137 -13
  39. package/docs/status-line.md +121 -1
  40. package/docs/testing.md +3 -1
  41. package/hooks/hooks.json +2 -2
  42. package/hooks/lang/en.json +21 -0
  43. package/hooks/lang/tr.json +22 -1
  44. package/hooks/projects-stop.py +2 -1
  45. package/hooks/tezgah_agents.py +65 -25
  46. package/hooks/tezgah_apps.py +18 -0
  47. package/hooks/tezgah_attest.py +79 -10
  48. package/hooks/tezgah_components.py +4 -0
  49. package/hooks/tezgah_context.py +207 -55
  50. package/hooks/tezgah_extension.py +337 -0
  51. package/hooks/tezgah_gate.py +267 -79
  52. package/hooks/tezgah_integrity.py +864 -143
  53. package/hooks/tezgah_judge.py +6 -4
  54. package/hooks/tezgah_models.py +59 -19
  55. package/hooks/tezgah_orca.py +1 -1
  56. package/hooks/tezgah_paths.py +145 -8
  57. package/hooks/tezgah_policy.py +128 -57
  58. package/hooks/tezgah_rank.py +9 -6
  59. package/hooks/tezgah_replay.py +12 -6
  60. package/hooks/tezgah_research.py +1414 -76
  61. package/hooks/tezgah_research_capture.py +1544 -0
  62. package/hooks/tezgah_research_evidence.py +842 -0
  63. package/hooks/tezgah_research_stats.py +258 -0
  64. package/hooks/tezgah_skill_pick.py +408 -106
  65. package/hooks/tezgah_stack.py +236 -1
  66. package/hooks/tezgah_stopspec.py +8 -1
  67. package/hooks/tezgah_task.py +14 -5
  68. package/hooks/tezgah_taste_ledger.py +1 -1
  69. package/hosts/amp/hook.py +217 -0
  70. package/hosts/amp/tezgah-plugin.ts.in +386 -0
  71. package/hosts/antigravity/hook.py +255 -0
  72. package/hosts/codex/hook.py +2 -1
  73. package/hosts/commandcode/hook.py +239 -0
  74. package/hosts/copilot/hook.py +284 -0
  75. package/hosts/cursor/hook.py +31 -3
  76. package/hosts/droid/hook.py +259 -0
  77. package/hosts/dsh/hooks.json +1 -1
  78. package/hosts/gemini/hook.py +213 -0
  79. package/hosts/goose/hook.py +145 -0
  80. package/hosts/omp/hook.py +4 -1
  81. package/hosts/omp/tezgah-hook.ts.in +30 -7
  82. package/hosts/opencode/plugins/tezgah.js +309 -48
  83. package/hosts/opencode/tui/cli/tui.tsx +100 -0
  84. package/hosts/opencode/tui/tezgah-tui.tsx +2 -1
  85. package/hosts/qwen/hook.py +206 -0
  86. package/output-styles/tezgah.md +20 -15
  87. package/package.json +1 -1
  88. package/skills/ai-research/SKILL.md +21 -14
  89. package/skills/ai-research/SOURCE +7 -5
  90. package/skills/ai-research/index/1-frame.md +6 -6
  91. package/skills/ai-research/index/2-data.md +9 -9
  92. package/skills/ai-research/index/3-train.md +36 -36
  93. package/skills/ai-research/index/4-measure.md +13 -13
  94. package/skills/ai-research/index/5-run.md +11 -11
  95. package/skills/ai-research/index/6-write.md +23 -23
  96. package/skills/ai-research/library.json +104 -104
  97. package/skills/analyze-app/SKILL.md +17 -12
  98. package/skills/design-contract/SKILL.md +42 -7
  99. package/skills/design-contract/data-views.md +2 -2
  100. package/skills/design-library/EVALS.md +1 -1
  101. package/skills/design-library/INDEX.md +39 -39
  102. package/skills/design-library/SKILL.md +19 -13
  103. package/skills/design-library/SOURCE +45 -43
  104. package/skills/feature-audit/SKILL.md +20 -14
  105. package/skills/hallmark/LICENSE +21 -0
  106. package/skills/hallmark/SKILL.md +154 -0
  107. package/skills/hallmark/SOURCE +207 -0
  108. package/skills/hallmark/agents/openai.yaml +13 -0
  109. package/skills/hallmark/references/anatomy.md +71 -0
  110. package/skills/hallmark/references/brand-truth.md +40 -0
  111. package/skills/hallmark/references/comp.md +104 -0
  112. package/skills/hallmark/references/craft.md +116 -0
  113. package/skills/hallmark/references/dark-mode.md +49 -0
  114. package/skills/hallmark/references/data-viz.md +48 -0
  115. package/skills/hallmark/references/design-md.md +53 -0
  116. package/skills/hallmark/references/direction.md +52 -0
  117. package/skills/hallmark/references/export-formats.md +80 -0
  118. package/skills/hallmark/references/flow.md +131 -0
  119. package/skills/hallmark/references/fonts.md +58 -0
  120. package/skills/hallmark/references/gates.md +243 -0
  121. package/skills/hallmark/references/genres/atmospheric.md +24 -0
  122. package/skills/hallmark/references/genres/editorial.md +24 -0
  123. package/skills/hallmark/references/genres/modern-minimal.md +24 -0
  124. package/skills/hallmark/references/genres/playful.md +24 -0
  125. package/skills/hallmark/references/imagery.md +99 -0
  126. package/skills/hallmark/references/interaction-and-states.md +89 -0
  127. package/skills/hallmark/references/layout.md +104 -0
  128. package/skills/hallmark/references/macrostructures/01-bento-grid.md +21 -0
  129. package/skills/hallmark/references/macrostructures/02-long-document.md +35 -0
  130. package/skills/hallmark/references/macrostructures/03-marquee-hero.md +21 -0
  131. package/skills/hallmark/references/macrostructures/04-stat-led.md +21 -0
  132. package/skills/hallmark/references/macrostructures/05-workbench.md +21 -0
  133. package/skills/hallmark/references/macrostructures/06-conversational-faq.md +28 -0
  134. package/skills/hallmark/references/macrostructures/07-manifesto.md +28 -0
  135. package/skills/hallmark/references/macrostructures/08-photographic.md +21 -0
  136. package/skills/hallmark/references/macrostructures/09-quote-led.md +21 -0
  137. package/skills/hallmark/references/macrostructures/10-specimen.md +21 -0
  138. package/skills/hallmark/references/macrostructures/11-catalogue.md +21 -0
  139. package/skills/hallmark/references/macrostructures/12-letter.md +21 -0
  140. package/skills/hallmark/references/macrostructures/13-index-first.md +21 -0
  141. package/skills/hallmark/references/macrostructures/14-narrative-workflow.md +21 -0
  142. package/skills/hallmark/references/macrostructures/15-split-studio.md +21 -0
  143. package/skills/hallmark/references/macrostructures/16-feature-stack.md +23 -0
  144. package/skills/hallmark/references/macrostructures/17-type-specimen.md +19 -0
  145. package/skills/hallmark/references/macrostructures/18-portfolio-grid.md +21 -0
  146. package/skills/hallmark/references/macrostructures/19-map-diagram.md +21 -0
  147. package/skills/hallmark/references/macrostructures/20-ecosystem-index.md +19 -0
  148. package/skills/hallmark/references/macrostructures/21-component-playground.md +21 -0
  149. package/skills/hallmark/references/macrostructures.md +119 -0
  150. package/skills/hallmark/references/microinteractions.md +80 -0
  151. package/skills/hallmark/references/patterns.md +202 -0
  152. package/skills/hallmark/references/redesign.md +88 -0
  153. package/skills/hallmark/references/responsive.md +46 -0
  154. package/skills/hallmark/references/review-finish.md +19 -0
  155. package/skills/hallmark/references/review-hero.md +20 -0
  156. package/skills/hallmark/references/scroll-choreography.md +110 -0
  157. package/skills/hallmark/references/texture.md +90 -0
  158. package/skills/hallmark/references/themes/README.md +58 -0
  159. package/skills/hallmark/references/themes/almanac.jpg +0 -0
  160. package/skills/hallmark/references/themes/almanac.md +103 -0
  161. package/skills/hallmark/references/themes/arcade.jpg +0 -0
  162. package/skills/hallmark/references/themes/arcade.md +102 -0
  163. package/skills/hallmark/references/themes/atelier.jpg +0 -0
  164. package/skills/hallmark/references/themes/atelier.md +98 -0
  165. package/skills/hallmark/references/themes/bloom.jpg +0 -0
  166. package/skills/hallmark/references/themes/bloom.md +102 -0
  167. package/skills/hallmark/references/themes/blueprint.jpg +0 -0
  168. package/skills/hallmark/references/themes/blueprint.md +103 -0
  169. package/skills/hallmark/references/themes/broadside.jpg +0 -0
  170. package/skills/hallmark/references/themes/broadside.md +108 -0
  171. package/skills/hallmark/references/themes/brutal.jpg +0 -0
  172. package/skills/hallmark/references/themes/brutal.md +95 -0
  173. package/skills/hallmark/references/themes/carnival.jpg +0 -0
  174. package/skills/hallmark/references/themes/carnival.md +104 -0
  175. package/skills/hallmark/references/themes/cobalt.jpg +0 -0
  176. package/skills/hallmark/references/themes/cobalt.md +101 -0
  177. package/skills/hallmark/references/themes/console.jpg +0 -0
  178. package/skills/hallmark/references/themes/console.md +99 -0
  179. package/skills/hallmark/references/themes/coral.jpg +0 -0
  180. package/skills/hallmark/references/themes/coral.md +102 -0
  181. package/skills/hallmark/references/themes/darkroom.jpg +0 -0
  182. package/skills/hallmark/references/themes/darkroom.md +99 -0
  183. package/skills/hallmark/references/themes/dose.jpg +0 -0
  184. package/skills/hallmark/references/themes/dose.md +98 -0
  185. package/skills/hallmark/references/themes/dusk.jpg +0 -0
  186. package/skills/hallmark/references/themes/dusk.md +105 -0
  187. package/skills/hallmark/references/themes/editorial.jpg +0 -0
  188. package/skills/hallmark/references/themes/editorial.md +97 -0
  189. package/skills/hallmark/references/themes/formwork.jpg +0 -0
  190. package/skills/hallmark/references/themes/formwork.md +107 -0
  191. package/skills/hallmark/references/themes/furrow.jpg +0 -0
  192. package/skills/hallmark/references/themes/furrow.md +98 -0
  193. package/skills/hallmark/references/themes/garden.jpg +0 -0
  194. package/skills/hallmark/references/themes/garden.md +97 -0
  195. package/skills/hallmark/references/themes/grid.jpg +0 -0
  196. package/skills/hallmark/references/themes/grid.md +101 -0
  197. package/skills/hallmark/references/themes/halftone.jpg +0 -0
  198. package/skills/hallmark/references/themes/halftone.md +106 -0
  199. package/skills/hallmark/references/themes/hedgerow.jpg +0 -0
  200. package/skills/hallmark/references/themes/hedgerow.md +104 -0
  201. package/skills/hallmark/references/themes/hum.jpg +0 -0
  202. package/skills/hallmark/references/themes/hum.md +99 -0
  203. package/skills/hallmark/references/themes/index.json +2191 -0
  204. package/skills/hallmark/references/themes/ink.jpg +0 -0
  205. package/skills/hallmark/references/themes/ink.md +106 -0
  206. package/skills/hallmark/references/themes/kiln.jpg +0 -0
  207. package/skills/hallmark/references/themes/kiln.md +108 -0
  208. package/skills/hallmark/references/themes/loom.jpg +0 -0
  209. package/skills/hallmark/references/themes/loom.md +106 -0
  210. package/skills/hallmark/references/themes/lumen.jpg +0 -0
  211. package/skills/hallmark/references/themes/lumen.md +103 -0
  212. package/skills/hallmark/references/themes/manifesto.jpg +0 -0
  213. package/skills/hallmark/references/themes/manifesto.md +101 -0
  214. package/skills/hallmark/references/themes/midnight.jpg +0 -0
  215. package/skills/hallmark/references/themes/midnight.md +97 -0
  216. package/skills/hallmark/references/themes/newsprint.jpg +0 -0
  217. package/skills/hallmark/references/themes/newsprint.md +102 -0
  218. package/skills/hallmark/references/themes/overprint.jpg +0 -0
  219. package/skills/hallmark/references/themes/overprint.md +101 -0
  220. package/skills/hallmark/references/themes/pasteup.jpg +0 -0
  221. package/skills/hallmark/references/themes/pasteup.md +100 -0
  222. package/skills/hallmark/references/themes/piste.jpg +0 -0
  223. package/skills/hallmark/references/themes/piste.md +99 -0
  224. package/skills/hallmark/references/themes/ribbon.jpg +0 -0
  225. package/skills/hallmark/references/themes/ribbon.md +106 -0
  226. package/skills/hallmark/references/themes/rim.jpg +0 -0
  227. package/skills/hallmark/references/themes/rim.md +96 -0
  228. package/skills/hallmark/references/themes/riso.jpg +0 -0
  229. package/skills/hallmark/references/themes/riso.md +98 -0
  230. package/skills/hallmark/references/themes/solstice.jpg +0 -0
  231. package/skills/hallmark/references/themes/solstice.md +104 -0
  232. package/skills/hallmark/references/themes/specimen.jpg +0 -0
  233. package/skills/hallmark/references/themes/specimen.md +100 -0
  234. package/skills/hallmark/references/themes/spore.jpg +0 -0
  235. package/skills/hallmark/references/themes/spore.md +104 -0
  236. package/skills/hallmark/references/themes/sport.jpg +0 -0
  237. package/skills/hallmark/references/themes/sport.md +101 -0
  238. package/skills/hallmark/references/themes/squeeze.jpg +0 -0
  239. package/skills/hallmark/references/themes/squeeze.md +99 -0
  240. package/skills/hallmark/references/themes/sticker.jpg +0 -0
  241. package/skills/hallmark/references/themes/sticker.md +106 -0
  242. package/skills/hallmark/references/themes/studio.jpg +0 -0
  243. package/skills/hallmark/references/themes/studio.md +99 -0
  244. package/skills/hallmark/references/themes/terminal.jpg +0 -0
  245. package/skills/hallmark/references/themes/terminal.md +96 -0
  246. package/skills/hallmark/references/themes/terrazzo.jpg +0 -0
  247. package/skills/hallmark/references/themes/terrazzo.md +106 -0
  248. package/skills/hallmark/references/themes/ultraviolet.jpg +0 -0
  249. package/skills/hallmark/references/themes/ultraviolet.md +106 -0
  250. package/skills/hallmark/references/themes/vellum.jpg +0 -0
  251. package/skills/hallmark/references/themes/vellum.md +105 -0
  252. package/skills/hallmark/references/themes/wayfind.jpg +0 -0
  253. package/skills/hallmark/references/themes/wayfind.md +105 -0
  254. package/skills/hallmark/references/themes/werkstatt.jpg +0 -0
  255. package/skills/hallmark/references/themes/werkstatt.md +105 -0
  256. package/skills/hallmark/scripts/capture.mjs +125 -0
  257. package/skills/hallmark/scripts/fixtures/exemplars/atmospheric.html +324 -0
  258. package/skills/hallmark/scripts/fixtures/exemplars/editorial.html +363 -0
  259. package/skills/hallmark/scripts/fixtures/exemplars/modern-minimal.html +527 -0
  260. package/skills/hallmark/scripts/fixtures/exemplars/playful.html +353 -0
  261. package/skills/hallmark/scripts/fixtures/eyebrow-fail/index.html +118 -0
  262. package/skills/hallmark/scripts/fixtures/eyebrow-pass/index.html +137 -0
  263. package/skills/hallmark/scripts/fixtures/kept/after.html +27 -0
  264. package/skills/hallmark/scripts/fixtures/kept/before.html +26 -0
  265. package/skills/hallmark/scripts/fixtures/slopped.html +145 -0
  266. package/skills/hallmark/scripts/fixtures/tailwind-fail/Feature.vue +14 -0
  267. package/skills/hallmark/scripts/fixtures/tailwind-fail/Hero.tsx +33 -0
  268. package/skills/hallmark/scripts/fixtures/tailwind-fail/index.html +74 -0
  269. package/skills/hallmark/scripts/fixtures/tailwind-fail/style.css +6 -0
  270. package/skills/hallmark/scripts/fixtures/tailwind-pass/Hero.tsx +33 -0
  271. package/skills/hallmark/scripts/fixtures/tailwind-pass/index.html +57 -0
  272. package/skills/hallmark/scripts/fixtures/tailwind-pass/style.css +15 -0
  273. package/skills/hallmark/scripts/fixtures/waiver/index.html +54 -0
  274. package/skills/hallmark/scripts/imagegen.mjs +85 -0
  275. package/skills/hallmark/scripts/install-hook.mjs +70 -0
  276. package/skills/hallmark/scripts/install.mjs +141 -0
  277. package/skills/hallmark/scripts/lint-hook.mjs +49 -0
  278. package/skills/hallmark/scripts/photos.json +1 -0
  279. package/skills/hallmark/scripts/picture.mjs +71 -0
  280. package/skills/hallmark/scripts/refcheck.mjs +352 -0
  281. package/skills/hallmark/scripts/sloplint-render.mjs +1252 -0
  282. package/skills/hallmark/scripts/sloplint.mjs +4422 -0
  283. package/skills/hallmark/scripts/theme.mjs +650 -0
  284. package/skills/harness/SKILL.md +17 -8
  285. package/skills/i-have-adhd/SKILL.md +32 -12
  286. package/skills/no-ai-slop/SKILL.md +15 -1
  287. package/skills/plan-add/SKILL.md +38 -27
  288. package/skills/plan-status/SKILL.md +13 -7
  289. package/skills/plan-sync/SKILL.md +18 -12
  290. package/skills/pm-frameworks/SKILL.md +19 -10
  291. package/skills/pm-frameworks/SOURCE +7 -5
  292. package/skills/ponytail/SKILL.md +19 -14
  293. package/skills/product-analysis/SKILL.md +22 -18
  294. package/skills/product-flow/SKILL.md +14 -9
  295. package/skills/research/SKILL.md +97 -48
  296. package/skills/rl-env/SKILL.md +25 -13
  297. package/skills/rl-env/SOURCE +8 -6
  298. package/skills/tezgah-contract/SKILL.md +65 -47
  299. package/statusline.py +20 -9
  300. package/workflows/graph-review.js +14 -2
  301. package/bin/tezgah-skill.py +0 -59
  302. /package/skills/ai-research/0-autoresearch-skill/{SKILL.md → ENTRY.md} +0 -0
  303. /package/skills/ai-research/01-model-architecture/litgpt/{SKILL.md → ENTRY.md} +0 -0
  304. /package/skills/ai-research/01-model-architecture/mamba/{SKILL.md → ENTRY.md} +0 -0
  305. /package/skills/ai-research/01-model-architecture/nanogpt/{SKILL.md → ENTRY.md} +0 -0
  306. /package/skills/ai-research/01-model-architecture/rwkv/{SKILL.md → ENTRY.md} +0 -0
  307. /package/skills/ai-research/01-model-architecture/torchtitan/{SKILL.md → ENTRY.md} +0 -0
  308. /package/skills/ai-research/02-tokenization/huggingface-tokenizers/{SKILL.md → ENTRY.md} +0 -0
  309. /package/skills/ai-research/02-tokenization/sentencepiece/{SKILL.md → ENTRY.md} +0 -0
  310. /package/skills/ai-research/03-fine-tuning/axolotl/{SKILL.md → ENTRY.md} +0 -0
  311. /package/skills/ai-research/03-fine-tuning/llama-factory/{SKILL.md → ENTRY.md} +0 -0
  312. /package/skills/ai-research/03-fine-tuning/peft/{SKILL.md → ENTRY.md} +0 -0
  313. /package/skills/ai-research/03-fine-tuning/unsloth/{SKILL.md → ENTRY.md} +0 -0
  314. /package/skills/ai-research/04-mechanistic-interpretability/nnsight/{SKILL.md → ENTRY.md} +0 -0
  315. /package/skills/ai-research/04-mechanistic-interpretability/pyvene/{SKILL.md → ENTRY.md} +0 -0
  316. /package/skills/ai-research/04-mechanistic-interpretability/saelens/{SKILL.md → ENTRY.md} +0 -0
  317. /package/skills/ai-research/04-mechanistic-interpretability/transformer-lens/{SKILL.md → ENTRY.md} +0 -0
  318. /package/skills/ai-research/05-data-processing/nemo-curator/{SKILL.md → ENTRY.md} +0 -0
  319. /package/skills/ai-research/05-data-processing/ray-data/{SKILL.md → ENTRY.md} +0 -0
  320. /package/skills/ai-research/06-post-training/grpo-rl-training/{SKILL.md → ENTRY.md} +0 -0
  321. /package/skills/ai-research/06-post-training/miles/{SKILL.md → ENTRY.md} +0 -0
  322. /package/skills/ai-research/06-post-training/openrlhf/{SKILL.md → ENTRY.md} +0 -0
  323. /package/skills/ai-research/06-post-training/simpo/{SKILL.md → ENTRY.md} +0 -0
  324. /package/skills/ai-research/06-post-training/slime/{SKILL.md → ENTRY.md} +0 -0
  325. /package/skills/ai-research/06-post-training/torchforge/{SKILL.md → ENTRY.md} +0 -0
  326. /package/skills/ai-research/06-post-training/trl-fine-tuning/{SKILL.md → ENTRY.md} +0 -0
  327. /package/skills/ai-research/06-post-training/verl/{SKILL.md → ENTRY.md} +0 -0
  328. /package/skills/ai-research/07-safety-alignment/constitutional-ai/{SKILL.md → ENTRY.md} +0 -0
  329. /package/skills/ai-research/07-safety-alignment/llamaguard/{SKILL.md → ENTRY.md} +0 -0
  330. /package/skills/ai-research/07-safety-alignment/nemo-guardrails/{SKILL.md → ENTRY.md} +0 -0
  331. /package/skills/ai-research/07-safety-alignment/prompt-guard/{SKILL.md → ENTRY.md} +0 -0
  332. /package/skills/ai-research/08-distributed-training/accelerate/{SKILL.md → ENTRY.md} +0 -0
  333. /package/skills/ai-research/08-distributed-training/deepspeed/{SKILL.md → ENTRY.md} +0 -0
  334. /package/skills/ai-research/08-distributed-training/megatron-core/{SKILL.md → ENTRY.md} +0 -0
  335. /package/skills/ai-research/08-distributed-training/pytorch-fsdp2/{SKILL.md → ENTRY.md} +0 -0
  336. /package/skills/ai-research/08-distributed-training/pytorch-lightning/{SKILL.md → ENTRY.md} +0 -0
  337. /package/skills/ai-research/08-distributed-training/ray-train/{SKILL.md → ENTRY.md} +0 -0
  338. /package/skills/ai-research/09-infrastructure/lambda-labs/{SKILL.md → ENTRY.md} +0 -0
  339. /package/skills/ai-research/09-infrastructure/modal/{SKILL.md → ENTRY.md} +0 -0
  340. /package/skills/ai-research/09-infrastructure/skypilot/{SKILL.md → ENTRY.md} +0 -0
  341. /package/skills/ai-research/10-optimization/awq/{SKILL.md → ENTRY.md} +0 -0
  342. /package/skills/ai-research/10-optimization/bitsandbytes/{SKILL.md → ENTRY.md} +0 -0
  343. /package/skills/ai-research/10-optimization/flash-attention/{SKILL.md → ENTRY.md} +0 -0
  344. /package/skills/ai-research/10-optimization/gguf/{SKILL.md → ENTRY.md} +0 -0
  345. /package/skills/ai-research/10-optimization/gptq/{SKILL.md → ENTRY.md} +0 -0
  346. /package/skills/ai-research/10-optimization/hqq/{SKILL.md → ENTRY.md} +0 -0
  347. /package/skills/ai-research/10-optimization/ml-training-recipes/{SKILL.md → ENTRY.md} +0 -0
  348. /package/skills/ai-research/11-evaluation/bigcode-evaluation-harness/{SKILL.md → ENTRY.md} +0 -0
  349. /package/skills/ai-research/11-evaluation/lm-evaluation-harness/{SKILL.md → ENTRY.md} +0 -0
  350. /package/skills/ai-research/11-evaluation/nemo-evaluator/{SKILL.md → ENTRY.md} +0 -0
  351. /package/skills/ai-research/12-inference-serving/llama-cpp/{SKILL.md → ENTRY.md} +0 -0
  352. /package/skills/ai-research/12-inference-serving/sglang/{SKILL.md → ENTRY.md} +0 -0
  353. /package/skills/ai-research/12-inference-serving/tensorrt-llm/{SKILL.md → ENTRY.md} +0 -0
  354. /package/skills/ai-research/12-inference-serving/vllm/{SKILL.md → ENTRY.md} +0 -0
  355. /package/skills/ai-research/13-mlops/mlflow/{SKILL.md → ENTRY.md} +0 -0
  356. /package/skills/ai-research/13-mlops/swanlab/{SKILL.md → ENTRY.md} +0 -0
  357. /package/skills/ai-research/13-mlops/tensorboard/{SKILL.md → ENTRY.md} +0 -0
  358. /package/skills/ai-research/13-mlops/weights-and-biases/{SKILL.md → ENTRY.md} +0 -0
  359. /package/skills/ai-research/14-agents/a-evolve/{SKILL.md → ENTRY.md} +0 -0
  360. /package/skills/ai-research/14-agents/autogpt/{SKILL.md → ENTRY.md} +0 -0
  361. /package/skills/ai-research/14-agents/crewai/{SKILL.md → ENTRY.md} +0 -0
  362. /package/skills/ai-research/14-agents/langchain/{SKILL.md → ENTRY.md} +0 -0
  363. /package/skills/ai-research/14-agents/llamaindex/{SKILL.md → ENTRY.md} +0 -0
  364. /package/skills/ai-research/15-rag/chroma/{SKILL.md → ENTRY.md} +0 -0
  365. /package/skills/ai-research/15-rag/faiss/{SKILL.md → ENTRY.md} +0 -0
  366. /package/skills/ai-research/15-rag/pinecone/{SKILL.md → ENTRY.md} +0 -0
  367. /package/skills/ai-research/15-rag/qdrant/{SKILL.md → ENTRY.md} +0 -0
  368. /package/skills/ai-research/15-rag/sentence-transformers/{SKILL.md → ENTRY.md} +0 -0
  369. /package/skills/ai-research/16-prompt-engineering/dspy/{SKILL.md → ENTRY.md} +0 -0
  370. /package/skills/ai-research/16-prompt-engineering/guidance/{SKILL.md → ENTRY.md} +0 -0
  371. /package/skills/ai-research/16-prompt-engineering/instructor/{SKILL.md → ENTRY.md} +0 -0
  372. /package/skills/ai-research/16-prompt-engineering/outlines/{SKILL.md → ENTRY.md} +0 -0
  373. /package/skills/ai-research/17-observability/langsmith/{SKILL.md → ENTRY.md} +0 -0
  374. /package/skills/ai-research/17-observability/phoenix/{SKILL.md → ENTRY.md} +0 -0
  375. /package/skills/ai-research/18-multimodal/audiocraft/{SKILL.md → ENTRY.md} +0 -0
  376. /package/skills/ai-research/18-multimodal/blip-2/{SKILL.md → ENTRY.md} +0 -0
  377. /package/skills/ai-research/18-multimodal/clip/{SKILL.md → ENTRY.md} +0 -0
  378. /package/skills/ai-research/18-multimodal/cosmos-policy/{SKILL.md → ENTRY.md} +0 -0
  379. /package/skills/ai-research/18-multimodal/llava/{SKILL.md → ENTRY.md} +0 -0
  380. /package/skills/ai-research/18-multimodal/openpi/{SKILL.md → ENTRY.md} +0 -0
  381. /package/skills/ai-research/18-multimodal/openvla-oft/{SKILL.md → ENTRY.md} +0 -0
  382. /package/skills/ai-research/18-multimodal/segment-anything/{SKILL.md → ENTRY.md} +0 -0
  383. /package/skills/ai-research/18-multimodal/stable-diffusion/{SKILL.md → ENTRY.md} +0 -0
  384. /package/skills/ai-research/18-multimodal/whisper/{SKILL.md → ENTRY.md} +0 -0
  385. /package/skills/ai-research/19-emerging-techniques/knowledge-distillation/{SKILL.md → ENTRY.md} +0 -0
  386. /package/skills/ai-research/19-emerging-techniques/long-context/{SKILL.md → ENTRY.md} +0 -0
  387. /package/skills/ai-research/19-emerging-techniques/model-merging/{SKILL.md → ENTRY.md} +0 -0
  388. /package/skills/ai-research/19-emerging-techniques/model-pruning/{SKILL.md → ENTRY.md} +0 -0
  389. /package/skills/ai-research/19-emerging-techniques/moe-training/{SKILL.md → ENTRY.md} +0 -0
  390. /package/skills/ai-research/19-emerging-techniques/speculative-decoding/{SKILL.md → ENTRY.md} +0 -0
  391. /package/skills/ai-research/20-ml-paper-writing/academic-plotting/{SKILL.md → ENTRY.md} +0 -0
  392. /package/skills/ai-research/20-ml-paper-writing/ml-paper-writing/{SKILL.md → ENTRY.md} +0 -0
  393. /package/skills/ai-research/20-ml-paper-writing/presenting-conference-talks/{SKILL.md → ENTRY.md} +0 -0
  394. /package/skills/ai-research/20-ml-paper-writing/systems-paper-writing/{SKILL.md → ENTRY.md} +0 -0
  395. /package/skills/ai-research/21-research-ideation/brainstorming-research-ideas/{SKILL.md → ENTRY.md} +0 -0
  396. /package/skills/ai-research/21-research-ideation/creative-thinking-for-research/{SKILL.md → ENTRY.md} +0 -0
  397. /package/skills/ai-research/22-agent-native-research-artifact/compiler/{SKILL.md → ENTRY.md} +0 -0
  398. /package/skills/ai-research/22-agent-native-research-artifact/research-manager/{SKILL.md → ENTRY.md} +0 -0
  399. /package/skills/ai-research/22-agent-native-research-artifact/rigor-reviewer/{SKILL.md → ENTRY.md} +0 -0
  400. /package/skills/design-library/adaptive-interfaces/skills/colour-independence/{SKILL.md → ENTRY.md} +0 -0
  401. /package/skills/design-library/adaptive-interfaces/skills/flexible-typography/{SKILL.md → ENTRY.md} +0 -0
  402. /package/skills/design-library/adaptive-interfaces/skills/information-density/{SKILL.md → ENTRY.md} +0 -0
  403. /package/skills/design-library/adaptive-interfaces/skills/preference-audit/{SKILL.md → ENTRY.md} +0 -0
  404. /package/skills/design-library/adaptive-interfaces/skills/responsive-accessibility/{SKILL.md → ENTRY.md} +0 -0
  405. /package/skills/design-library/adaptive-interfaces/skills/responsive-review/{SKILL.md → ENTRY.md} +0 -0
  406. /package/skills/design-library/adaptive-interfaces/skills/simplified-views/{SKILL.md → ENTRY.md} +0 -0
  407. /package/skills/design-library/adaptive-interfaces/skills/specify/{SKILL.md → ENTRY.md} +0 -0
  408. /package/skills/design-library/adaptive-interfaces/skills/user-preference-respect/{SKILL.md → ENTRY.md} +0 -0
  409. /package/skills/design-library/cognitive-accessibility/skills/adaptive-personalisation/{SKILL.md → ENTRY.md} +0 -0
  410. /package/skills/design-library/cognitive-accessibility/skills/assess-load/{SKILL.md → ENTRY.md} +0 -0
  411. /package/skills/design-library/cognitive-accessibility/skills/cognitive-load-assessment/{SKILL.md → ENTRY.md} +0 -0
  412. /package/skills/design-library/cognitive-accessibility/skills/contextual-help-design/{SKILL.md → ENTRY.md} +0 -0
  413. /package/skills/design-library/cognitive-accessibility/skills/error-prevention-recovery/{SKILL.md → ENTRY.md} +0 -0
  414. /package/skills/design-library/cognitive-accessibility/skills/focus-attention-design/{SKILL.md → ENTRY.md} +0 -0
  415. /package/skills/design-library/cognitive-accessibility/skills/memory-load-reduction/{SKILL.md → ENTRY.md} +0 -0
  416. /package/skills/design-library/cognitive-accessibility/skills/plain-language-design/{SKILL.md → ENTRY.md} +0 -0
  417. /package/skills/design-library/cognitive-accessibility/skills/review/{SKILL.md → ENTRY.md} +0 -0
  418. /package/skills/design-library/cognitive-accessibility/skills/simplify/{SKILL.md → ENTRY.md} +0 -0
  419. /package/skills/design-library/cognitive-accessibility/skills/wayfinding-navigation/{SKILL.md → ENTRY.md} +0 -0
  420. /package/skills/design-library/design-research/skills/behavioural-analytics/{SKILL.md → ENTRY.md} +0 -0
  421. /package/skills/design-library/design-research/skills/qual-quant-triangulation/{SKILL.md → ENTRY.md} +0 -0
  422. /package/skills/design-library/inclusive-interaction/skills/audit/{SKILL.md → ENTRY.md} +0 -0
  423. /package/skills/design-library/inclusive-interaction/skills/design-flow/{SKILL.md → ENTRY.md} +0 -0
  424. /package/skills/design-library/inclusive-interaction/skills/feedback-and-status/{SKILL.md → ENTRY.md} +0 -0
  425. /package/skills/design-library/inclusive-interaction/skills/gesture-alternatives/{SKILL.md → ENTRY.md} +0 -0
  426. /package/skills/design-library/inclusive-interaction/skills/keyboard-navigation/{SKILL.md → ENTRY.md} +0 -0
  427. /package/skills/design-library/inclusive-interaction/skills/keyboard-review/{SKILL.md → ENTRY.md} +0 -0
  428. /package/skills/design-library/inclusive-interaction/skills/motion-sensitivity/{SKILL.md → ENTRY.md} +0 -0
  429. /package/skills/design-library/inclusive-interaction/skills/multi-modal-input/{SKILL.md → ENTRY.md} +0 -0
  430. /package/skills/design-library/inclusive-interaction/skills/touch-target-design/{SKILL.md → ENTRY.md} +0 -0
  431. /package/skills/design-library/inclusive-interaction/skills/voice-interaction/{SKILL.md → ENTRY.md} +0 -0
  432. /package/skills/design-library/visual-critique/skills/critique-affordance/{SKILL.md → ENTRY.md} +0 -0
  433. /package/skills/design-library/visual-critique/skills/critique-brand-consistency/{SKILL.md → ENTRY.md} +0 -0
  434. /package/skills/design-library/visual-critique/skills/critique-color/{SKILL.md → ENTRY.md} +0 -0
  435. /package/skills/design-library/visual-critique/skills/critique-composition/{SKILL.md → ENTRY.md} +0 -0
  436. /package/skills/design-library/visual-critique/skills/critique-information-density/{SKILL.md → ENTRY.md} +0 -0
  437. /package/skills/design-library/visual-critique/skills/critique-typography/{SKILL.md → ENTRY.md} +0 -0
  438. /package/skills/design-library/visual-critique/skills/critique-visual-hierarchy/{SKILL.md → ENTRY.md} +0 -0
  439. /package/skills/pm-frameworks/intended-vs-implemented/{SKILL.md → ENTRY.md} +0 -0
  440. /package/skills/pm-frameworks/opportunity-solution-tree/{SKILL.md → ENTRY.md} +0 -0
  441. /package/skills/rl-env/generate-nemo-gym-env/{SKILL.md → ENTRY.md} +0 -0
  442. /package/skills/rl-env/generate-openenv-env/{SKILL.md → ENTRY.md} +0 -0
  443. /package/skills/rl-env/generate-ors-env/{SKILL.md → ENTRY.md} +0 -0
  444. /package/skills/rl-env/generate-verifiers-env/{SKILL.md → ENTRY.md} +0 -0
  445. /package/skills/rl-env/rl-env-from-description/{SKILL.md → ENTRY.md} +0 -0
@@ -31,6 +31,7 @@ import re
31
31
  import shlex
32
32
  import statistics
33
33
  import time
34
+ import urllib.parse
34
35
 
35
36
  try:
36
37
  import fcntl
@@ -49,12 +50,17 @@ import tezgah_store as ts
49
50
  VERIFY = re.compile(
50
51
  r"(?:^|[|;&(])\s*"
51
52
  r"(?:(?:sudo|env|time|nohup|command|exec)\s+(?:\w+=\S+\s+)*(?:-\S+\s+)*)?"
52
- r"(?:" r"(?:python3?|uv run)\s+(?:-m\s+)?(?:pytest|unittest|mypy|ruff|flake8|compileall|"
53
- r"(?:[\w./-]*/)?tezgah-design\s+(?:check|derive)|"
53
+ r"(?:" r"(?:python3?|uv run)\s+(?:(?:--project|--with|--python|--extra|--group|-p|-w)\s+\S+\s+|-\S+\s+)*(?:python3?\s+-m\s+)?(?:pytest|unittest|mypy|ruff|flake8|compileall|"
54
+ r"(?:[\w./-]*/)?tezgah-design\s+(?:check|derive|slop)|"
54
55
  r"(?:[\w./-]*/)?tests[/\\]impacted\.py)|"
55
56
  r"pytest|py\.test|"
56
- r"npm\s+(?:test|t\b)|npm\s+run\s+\S*(?:test|lint|typecheck|check|build|ci)|"
57
- r"(?:yarn|pnpm|bun)\s+(?:test|run\s+\S*(?:test|lint|build|check))|"
57
+ # A runner may carry a flag (and its value) before its task: a monorepo's
58
+ # `pnpm --filter mobile test` was read as a plain command, so the check it
59
+ # ran recorded as `run` and the Stop rule refused the claim it supported
60
+ # (plan 135: 11 of the post-2026-10-07 window's blocked claims, 2 before).
61
+ r"(?:npm|yarn|pnpm|bun)\s+(?:(?:--filter|--prefix|--workspace|--cwd|--dir|-C|-w)\s+\S+\s+|-\S+\s+)*"
62
+ r"(?:test\b|t\b|run\s+(?:-\S+\s+)*\S*(?:test|lint|typecheck|check|build|ci)|"
63
+ r"workspace\s+\S+\s+(?:test\b|t\b|run\s+\S*(?:test|lint|typecheck|check|build|ci)))|"
58
64
  r"ruff|flake8|mypy|pyright|tsc|eslint|prettier|"
59
65
  r"vitest|jest|ava|mocha|"
60
66
  r"go\s+(?:test|vet)|"
@@ -66,12 +72,12 @@ VERIFY = re.compile(
66
72
  r"swift\s+test|golangci-lint|shellcheck|"
67
73
  r"playwright|cypress|storybook|chromatic|percy|lighthouse|pa11y|backstop|"
68
74
  r"axe-core|reg-suit|"
69
- r"(?:[\w./-]*/)?tezgah-design\s+(?:check|derive)|"
75
+ r"(?:[\w./-]*/)?tezgah-design\s+(?:check|derive|slop)|"
70
76
  r"(?:[\w./-]*/)?tests[/\\]impacted\.py|"
71
77
  # a JS tool behind its runner is still the check, and the name it returns is
72
78
  # the tool's, not the runner's: `npx axe-core` matched as `axe-core` before
73
79
  # the anchor was tightened, so the runner is outside the name group here
74
- r"(?:npx|npm(?:\s+run)?|pnpm(?:\s+(?:exec|dlx|run))?|yarn|bunx?|uvx)"
80
+ r"(?:npx|npm(?:\s+run)?|pnpm(?:\s+(?:(?:--filter|--prefix|--workspace|-C|-w)\s+\S+\s+|-\S+\s+)*(?:exec|dlx|run))?|yarn|bunx?|uvx)"
75
81
  r"\s+(?:-\S+\s+)*(?P<js>playwright|cypress|storybook|chromatic|percy|"
76
82
  r"lighthouse|pa11y|axe-core|backstop|reg-suit|vitest|jest|ava|mocha|eslint|"
77
83
  r"prettier|tsc|pyright)"
@@ -168,8 +174,14 @@ UI_TOOL_CMD = re.compile(
168
174
  # is read at a command position for `UI_CHECK`'s reason - a grep whose pattern is
169
175
  # `tezgah-design check` is text ABOUT the checker. The interpreter prefix is the
170
176
  # one the module's own refusal text and the skill both print.
177
+ DESIGN_SLOP = re.compile(r"(?:^|[|;&(])\s*(?:python3?\s+|uv\s+run\s+)?(?:\S*/)?"
178
+ r"tezgah-design\s+slop\b", re.I | re.M)
179
+ # `slop --repo <path>`: the tree a verdict scanned
180
+ SLOP_REPO = re.compile(r"tezgah-design\s+slop\b[^|;&]*?--repo\s+([^\s|;&]+)",
181
+ re.I)
171
182
  DESIGN_CHECK = re.compile(
172
- r"(?:^|[|;&(])\s*(?:python3?\s+|uv\s+run\s+)?(?:\S*/)?tezgah-design\s+check\b",
183
+ r"(?:^|[|;&(])\s*(?:python3?\s+|uv\s+run\s+)?(?:\S*/)?tezgah-design\s+"
184
+ r"(?:check|slop)\b",
173
185
  re.I | re.M)
174
186
  # A component, as opposed to the screen it sits on: a file under a
175
187
  # component/views/widgets directory, or a name that carries the convention on its
@@ -224,6 +236,9 @@ ROUGH_WORDS = re.compile(r"[;&|()]+|[^\s;&|()<>]+")
224
236
  # a redirection operator, or `&` in front of one (`&>`); shlex splits
225
237
  # `2>&1` into `2`, `>`, `&`, `1`, and none of those is an argument
226
238
  REDIRECTION = re.compile(r"^(?=[&<>]*[<>])[&<>]+$")
239
+ # an fd number glued to its redirection (`2>`, `1<&`): bash reads `1<` as fd 1
240
+ # and `1 <` as the program `1`, and shlex cannot tell them apart afterwards
241
+ FD_REDIRECT = re.compile(r"(?<![\w])(\d+)([<>])")
227
242
  # what a shell line puts between the start of a command and its program; the
228
243
  # same set in the JS mirror, so the two read a line identically
229
244
  GIT_WRAPPER = frozenset(("sudo", "env", "nohup", "time", "timeout", "command",
@@ -372,13 +387,22 @@ SHAPE_BLOCKS = frozenset(("placating opener", "forbidden closer", "list cap",
372
387
  # agent could decide is no false completion. Counted in `blocked_claims` under
373
388
  # their own class, kept out of `false_completion`.
374
389
  LIST_BLOCKS = frozenset(("residual work", "ready steps", "stopped slice",
375
- "needless ask"))
390
+ "needless ask", "handed step"))
391
+ # The host permission modes in which the session can neither write nor run: a
392
+ # plan-mode turn reads and proposes, so the step it names is the user's to
393
+ # approve, not one it could have taken (`read_only_turn`). Claude, Command
394
+ # Code and Qwen spell it `plan`, Droid `spec`; omp's extension sends `plan`
395
+ # from `sessionModes.getState()`. A host that sends no mode reads as `default`,
396
+ # which is what every rule did before the field.
397
+ READ_ONLY_MODES = frozenset(("plan", "spec"))
376
398
 
377
399
  WRITE_TOOLS = ("edit", "write", "multiedit", "notebookedit", "apply_patch",
378
400
  "str_replace_editor", "create_file", "str_replace", "edit_file",
379
401
  "write_file", "search_replace")
402
+ # `monitor` is Claude's and Qwen's background watch: its `command` is a shell
403
+ # command (review of plan 138), so every shell rule reads it.
380
404
  BASH_TOOLS = ("bash", "shell", "command", "exec_command", "run_command",
381
- "powershell", "pwsh")
405
+ "powershell", "pwsh", "monitor")
382
406
  # The read/search tools: known calls that do no step of work and that no rule
383
407
  # reads a row for, spelled as the hosts send them (Claude/Cursor's capitalized
384
408
  # set, omp's lowercase one, codex's `grep`). They are not `unknown` - the ledger
@@ -502,7 +526,7 @@ LEDGER_FIELDS = frozenset(("id", "exit", "out_bytes", "fail_class", "workspace",
502
526
  "check", "key", "block", "repo", "empty_run", "cause",
503
527
  "host", "switches", "harness", "authorized", "inflight",
504
528
  "reask", "open", "ready", "blocked", "heads", "residual",
505
- "questions", "defaults", "taken", "partial"))
529
+ "questions", "defaults", "taken", "partial", "mode"))
506
530
 
507
531
  # The row contract's own version, stamped by the writer beside `kind` and `ts` so
508
532
  # it is not a caller field. It exists because a row is read back to decide a
@@ -1125,16 +1149,49 @@ def prior_calls(session_id, digest, tail=200, agent=None):
1125
1149
  turn[-1].get("fail_class"))
1126
1150
 
1127
1151
 
1152
+ # Any `scheme://`: an internal channel (`agent://`, `local://`), a remote URL.
1153
+ # `file://` is read by `file_url_path`, the rest name no file on this disk.
1154
+ URI_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://")
1155
+
1156
+
1157
+ def file_url_path(text):
1158
+ """The local path a `file://` URL names, `text` itself when it carries no
1159
+ URI scheme, or None for any other URI and for a file URL that names no local
1160
+ file (a host other than `localhost`, no path, a malformed host). Read as
1161
+ `<cwd>/file:/x`, a URL into another checkout landed in the cwd's, one file
1162
+ spelled both ways was two, and `agent://<id>` was a product file (plan 116).
1163
+ ponytail: a shell redirect `> file:///x` writes `./file:/x`; it is read as
1164
+ `/x` here, a spelling no tool produces."""
1165
+ if not URI_SCHEME.match(text):
1166
+ return text
1167
+ if text[:7].lower() != "file://":
1168
+ return None
1169
+ try:
1170
+ parts = urllib.parse.urlsplit(text)
1171
+ except ValueError: # an unclosed `[` host
1172
+ return None
1173
+ if parts.netloc.lower() not in ("", "localhost") or not parts.path:
1174
+ return None
1175
+ return urllib.parse.unquote(parts.path)
1176
+
1177
+
1128
1178
  def _abs_target(path, cwd):
1129
- """`path` as an absolute real path, resolved against `cwd` when relative.
1179
+ """`path` as an absolute real path, resolved against `cwd` when relative, a
1180
+ local `file://` URL as the file it names; a URI that names no local file
1181
+ (`s3://b/k`, `agent://<id>`, a remote file URL) as its own spelling.
1130
1182
 
1131
1183
  The form both halves of the cross-session write guard compare: the writer
1132
1184
  stores it in an `edit` row's `target`, the gate resolves its own call's path
1133
1185
  the same way. A file that does not exist yet still resolves (realpath leaves
1134
- the missing tail as it is), so a new file two sessions both create matches."""
1135
- path = str(path or "").strip()
1136
- if not path:
1186
+ the missing tail as it is), so a new file two sessions both create matches,
1187
+ and two sessions writing one `s3://` key match on its spelling, whatever
1188
+ their cwds. The no-plan rule skips such a target (`file_url_path` is None)."""
1189
+ raw = str(path or "").strip()
1190
+ if not raw:
1137
1191
  return None
1192
+ path = file_url_path(raw)
1193
+ if path is None:
1194
+ return raw
1138
1195
  if not os.path.isabs(path):
1139
1196
  path = os.path.join(cwd or os.getcwd(), path)
1140
1197
  return os.path.realpath(path)
@@ -1459,6 +1516,23 @@ def _week(ts):
1459
1516
  return (int(ts or 0) - MONDAY) // WEEK * WEEK + MONDAY
1460
1517
 
1461
1518
 
1519
+ def day_start(text):
1520
+ """The epoch second a `YYYY-MM-DD` date starts at, in this machine's time
1521
+ zone, or None when the text is not that date. Whole calendar days are what
1522
+ a reader types and what a person reads on a calendar, so the window edges
1523
+ are local midnights; one format, never a guess at a date that was mistyped."""
1524
+ try:
1525
+ return int(time.mktime(time.strptime(str(text), "%Y-%m-%d")))
1526
+ except (TypeError, ValueError, OverflowError):
1527
+ return None
1528
+
1529
+
1530
+ def _day(ts):
1531
+ """The calendar day a row's `ts` falls on, as `YYYY-MM-DD` (local, the same
1532
+ edges `day_start` reads)."""
1533
+ return time.strftime("%Y-%m-%d", time.localtime(int(ts)))
1534
+
1535
+
1462
1536
  _CATALOG = {}
1463
1537
 
1464
1538
 
@@ -1727,6 +1801,18 @@ def _reissued(rows):
1727
1801
  return out
1728
1802
 
1729
1803
 
1804
+ def _claim_class(detail):
1805
+ """How a `claim` row's detail classifies: `(class, verdict)` where verdict is
1806
+ `ok` (nothing was refused), `shape` (the class in `SHAPE_BLOCKS` refuses the
1807
+ reply's form, not its claim) or `blocked` (the class refuses the claim).
1808
+ One reader for `_counts` and `rates`, so a corpus total and a window cannot
1809
+ disagree about what a `list cap` row is."""
1810
+ if not str(detail).startswith("blocked"):
1811
+ return None, "ok"
1812
+ cls = str(detail)[len("blocked: "):]
1813
+ return cls, ("shape" if cls in SHAPE_BLOCKS else "blocked")
1814
+
1815
+
1730
1816
  def _counts(rows, weeks=False, tools=False):
1731
1817
  """`counters`' arithmetic over rows already read: the one implementation
1732
1818
  both readers fold with, so a total and the sessions it sums cannot drift
@@ -1807,19 +1893,18 @@ def _counts(rows, weeks=False, tools=False):
1807
1893
  elif kind == "claim":
1808
1894
  out["claims"] += 1
1809
1895
  refused = False
1810
- if detail.startswith("blocked"):
1896
+ cls, verdict = _claim_class(detail)
1897
+ if verdict == "shape":
1811
1898
  # a reply blocked for its shape made no false claim: the rate
1812
1899
  # the module calls its effect must not count a list cap
1813
- if detail[len("blocked: "):] in SHAPE_BLOCKS:
1814
- out["shape_blocked"] += 1
1815
- else:
1816
- # split by the Stop class: which check refused, as a number
1817
- cls = detail[len("blocked: "):]
1818
- out["blocked_claims"][cls] = \
1819
- out["blocked_claims"].get(cls, 0) + 1
1820
- if cls not in LIST_BLOCKS:
1821
- out["false_completion"] += 1
1822
- refused = True
1900
+ out["shape_blocked"] += 1
1901
+ elif verdict == "blocked":
1902
+ # split by the Stop class: which check refused, as a number
1903
+ out["blocked_claims"][cls] = \
1904
+ out["blocked_claims"].get(cls, 0) + 1
1905
+ if cls not in LIST_BLOCKS:
1906
+ out["false_completion"] += 1
1907
+ refused = True
1823
1908
  if weeks:
1824
1909
  # the same bucket this row's events went into, so a claim cannot
1825
1910
  # sit in a week's total and outside its ratio
@@ -1884,27 +1969,204 @@ def _counts(rows, weeks=False, tools=False):
1884
1969
  return out
1885
1970
 
1886
1971
 
1887
- def _check_runs(cmd):
1972
+ # The windowed rates reader (plan 134). One bucket is filled once for the window
1973
+ # and once for each of its calendar days from the same rows, so a day and the
1974
+ # window it sits in cannot disagree, and two adjacent windows partition the span
1975
+ # because the filter is `[start, end)` on the row's own `ts`.
1976
+ def _rate_bucket():
1977
+ """An empty rates bucket: the counts the observation rows read, and the two
1978
+ histograms they split them by."""
1979
+ return {"events": 0, "turns": 0, "claims": 0, "replies": 0,
1980
+ "blocked_claims": {}, "shape_blocked": 0, "false_completion": 0,
1981
+ "shape_flagged": 0, "shapes": {}, "denies": {}, "row_versions": {}}
1982
+
1983
+
1984
+ # A row's contract version as the rates name it, the label `failure_shapes`
1985
+ # already prints: `v1`, or `unversioned` for a row the writer stamped nothing on.
1986
+ def _contract(value):
1987
+ return "unversioned" if value is None else "v%s" % value
1988
+
1989
+
1990
+ def _rate_row(bucket, entry):
1991
+ """One ledger row into a rates bucket: the classification `_counts` makes,
1992
+ and only the rows these rates read."""
1993
+ kind = str(entry.get("kind") or "")
1994
+ detail = str(entry.get("detail") or "")
1995
+ bucket["events"] += 1
1996
+ bucket["row_versions"][_contract(entry.get("v"))] = \
1997
+ bucket["row_versions"].get(_contract(entry.get("v")), 0) + 1
1998
+ if kind == TURN_KIND:
1999
+ bucket["turns"] += 1
2000
+ elif kind == "deny":
2001
+ rule = _deny_rule(detail)
2002
+ bucket["denies"][rule] = bucket["denies"].get(rule, 0) + 1
2003
+ elif kind == "claim":
2004
+ bucket["claims"] += 1
2005
+ cls, verdict = _claim_class(detail)
2006
+ if verdict == "shape":
2007
+ bucket["shape_blocked"] += 1
2008
+ elif verdict == "blocked":
2009
+ bucket["blocked_claims"][cls] = \
2010
+ bucket["blocked_claims"].get(cls, 0) + 1
2011
+ if cls not in LIST_BLOCKS:
2012
+ bucket["false_completion"] += 1
2013
+ elif kind == "shape":
2014
+ # one row per judged reply, its detail the flag list (`ok` when none):
2015
+ # `shape_flagged` is the reply count the rate needs, `shapes` the class
2016
+ # split - a count of flags, so a reply carrying two moves both
2017
+ bucket["replies"] += 1
2018
+ if detail and detail != "ok":
2019
+ bucket["shape_flagged"] += 1
2020
+ for flag in (f.strip() for f in detail.split(",")):
2021
+ if flag:
2022
+ bucket["shapes"][flag] = bucket["shapes"].get(flag, 0) + 1
2023
+
2024
+
2025
+ def _per_scale(scale, numerator, denominator):
2026
+ """`scale * n / d`, or None with no denominator: a window with no turn or no
2027
+ session has no rate, and a 0.0 there would read as a measured zero."""
2028
+ return round(scale * numerator / denominator, 4) if denominator else None
2029
+
2030
+
2031
+ def _window_rates(bucket, sessions):
2032
+ """The rates plan 066's rows read, off a filled bucket."""
2033
+ blocked = sum(bucket["blocked_claims"].values())
2034
+ return {
2035
+ # plan 134's pair: what a block and a deny cost per unit of use
2036
+ "blocks_per_100_turns": _per_scale(100, blocked, bucket["turns"]),
2037
+ "denies_per_100_sessions": _per_scale(
2038
+ 100, sum(bucket["denies"].values()), sessions),
2039
+ # 056's bar compares these two across adjacent windows, in pp: the share
2040
+ # of claims a Stop class refused, and the share of judged replies that
2041
+ # carried a report-only shape flag
2042
+ "claim_block_rate": _per_scale(1, blocked, bucket["claims"]),
2043
+ "stop_shape_rate": _per_scale(1, bucket["shape_flagged"],
2044
+ bucket["replies"]),
2045
+ # 051's bar is per class against live sessions: 1 per 500 reads 0.2 here
2046
+ "blocked_claims_per_100_sessions": {
2047
+ cls: _per_scale(100, n, sessions)
2048
+ for cls, n in bucket["blocked_claims"].items()},
2049
+ }
2050
+
2051
+
2052
+ def rates(since, until):
2053
+ """Every real-session ledger's rows stamped in `[since, until)`, folded per
2054
+ window and per calendar day: turns, `claim` rows and the Stop classes that
2055
+ blocked them, the shape flags every judged reply carried, denies by rule,
2056
+ and the rates plan 066's observation rows read (plan 134).
2057
+
2058
+ The window is this reader's; `counters_all` still folds a lifetime, because
2059
+ a total that contradicted the sessions it sums would be the wrong answer to
2060
+ its own question. `until` is exclusive, so two adjacent windows are a
2061
+ partition of the span and not an approximation of one - no row is counted
2062
+ twice or dropped at the seam. A row with no `ts` (a `ledger_damage` row
2063
+ stands in for bytes that could not be parsed) falls outside every window: it
2064
+ carries no time to place it in. `sessions` counts the real ledgers with at
2065
+ least one row in the window, the denominator 051's "live sessions" names;
2066
+ `days` is the window's own length and `by_day` holds only the days that saw
2067
+ a row. Both dates are `YYYY-MM-DD` local calendar days; a malformed or
2068
+ reversed pair is ValueError, never a silently empty window."""
2069
+ start, end = day_start(since), day_start(until)
2070
+ if start is None or end is None:
2071
+ raise ValueError("rates wants --since and --until as YYYY-MM-DD")
2072
+ if end <= start:
2073
+ raise ValueError("rates wants --until after --since")
2074
+ total = _rate_bucket()
2075
+ days, sessions = {}, 0
2076
+ for path in ledgers():
2077
+ rows = _foreign_rows(path)
2078
+ if fixture_ledger(rows):
2079
+ continue
2080
+ rows = [r for r in rows if isinstance(r.get("ts"), (int, float))
2081
+ and start <= r["ts"] < end]
2082
+ if not rows:
2083
+ continue
2084
+ sessions += 1
2085
+ for entry in rows:
2086
+ _rate_row(total, entry)
2087
+ _rate_row(days.setdefault(_day(entry["ts"]), _rate_bucket()), entry)
2088
+ return dict(total, since=str(since), until=str(until), sessions=sessions,
2089
+ days=int(round((end - start) / 86400.0)),
2090
+ by_day=[dict(bucket, day=day)
2091
+ for day, bucket in sorted(days.items())],
2092
+ rates=_window_rates(total, sessions))
2093
+
2094
+
2095
+ def _check_runs(cmd, cwd=None):
1888
2096
  """(name, mode) for every check-shaped command in `cmd`, in order. `mode` is
1889
2097
  `check`, `info` (INFO_ARGS / INFO_SUBCOMMAND: it checks nothing) or `write`
1890
2098
  (FORMAT_WRITE: it changes the tree), read on the words after the tool up to
1891
2099
  the end of that one command, so `pytest --version; pytest` still runs a
1892
- check."""
2100
+ check. With `cwd`, the repository's own declared checks (`repo_checks`)
2101
+ follow the built-in vocabulary's."""
1893
2102
  text = mask(cmd)
1894
2103
  for m in VERIFY.finditer(text):
1895
2104
  name = (m.groupdict().get("js") or m.group(0)).strip()
1896
2105
  tool = name.split()[-1].lower()
1897
2106
  args = COMMAND_END.split(text[m.end():], 1)[0]
1898
2107
  sub = INFO_SUBCOMMAND.get(tool)
1899
- if INFO_ARGS.search(args) or (sub and sub.match(args)):
2108
+ # the info flag may sit among the runner's own flags
2109
+ # (`npm --help test` runs nothing), so the whole match is read too
2110
+ if INFO_ARGS.search(m.group(0)) or INFO_ARGS.search(args) \
2111
+ or (sub and sub.match(args)):
1900
2112
  yield name, "info"
1901
2113
  elif tool in FORMAT_WRITE and FORMAT_WRITE[tool].search(args):
1902
2114
  yield name, "write"
1903
2115
  else:
1904
2116
  yield name, "check"
2117
+ prefixes = repo_checks(cwd)
2118
+ if prefixes:
2119
+ yield from _repo_check_runs(cmd, prefixes)
2120
+
2121
+
2122
+ # The words in front of a program that leave it the program: VERIFY's own
2123
+ # wrapper prefix, read on words instead of text.
2124
+ CHECK_WRAPPERS = frozenset(("sudo", "env", "time", "nohup", "command", "exec"))
2125
+ ASSIGNMENT_WORD = re.compile(r"^[A-Za-z_]\w*=")
2126
+
2127
+
2128
+ def repo_checks(cwd):
2129
+ """[argv prefix] the repository at `cwd` declares as checks in its manifest
2130
+ (`tezgah_extension.repo_checks`), or [] - no cwd, no manifest, a broken one,
2131
+ or a missing extension module. Imported here, not at the top: a broken
2132
+ extension costs the repository its declared checks, never the hook."""
2133
+ if not cwd:
2134
+ return []
2135
+ try:
2136
+ import tezgah_extension
2137
+ return tezgah_extension.repo_checks(cwd)
2138
+ except Exception:
2139
+ return []
2140
+
1905
2141
 
2142
+ def _repo_check_runs(cmd, prefixes):
2143
+ """(name, mode) for each simple command of `cmd` that starts with one of
2144
+ `prefixes`, after assignments and VERIFY's wrappers. Read with
2145
+ `_shell_commands`, so a prefix inside a quoted argument, a commit message or
2146
+ a QUOTED heredoc body (`<<'EOF'`, `<<"EOF"`) is an argument, not a run - the
2147
+ masked reading VERIFY keeps. An unquoted heredoc's body (`<<EOF`, `<<-EOF`)
2148
+ is read as commands, so `cat > x <<EOF` over a `./bin/run-tool` line counts
2149
+ as a run, as a `pytest` line there does for VERIFY.
2150
+ ponytail: a wrapper's own option (`sudo -u root ./bin/x`) hides the program,
2151
+ a lost credit rather than an invented one."""
2152
+ for words, _sep in _shell_commands(cmd):
2153
+ i = 0
2154
+ while i < len(words) and (words[i] in CHECK_WRAPPERS
2155
+ or ASSIGNMENT_WORD.match(words[i])):
2156
+ i += 1
2157
+ run = words[i:]
2158
+ if not run:
2159
+ continue
2160
+ head = [os.path.normpath(run[0])] + run[1:]
2161
+ for prefix in prefixes:
2162
+ if head[:len(prefix)] == prefix:
2163
+ name = " ".join(run[:len(prefix)])
2164
+ rest = " ".join(run[len(prefix):])
2165
+ yield name, ("info" if INFO_ARGS.search(rest) else "check")
2166
+ break
1906
2167
 
1907
- def verify_command(cmd):
2168
+
2169
+ def verify_command(cmd, cwd=None):
1908
2170
  """The name of the check this command runs, or None.
1909
2171
 
1910
2172
  The scan runs on the masked text, the convention `shortcut_command` already
@@ -1920,8 +2182,9 @@ def verify_command(cmd):
1920
2182
  (`_check_runs`), so every consumer - the evidence kind, the gate's retry
1921
2183
  exemption, its began `check` mark, `piped_check` and the NEUTER rule - reads
1922
2184
  them as the plain commands they are. A JS tool names itself, not its runner
1923
- (`npx axe-core` is `axe-core`)."""
1924
- return next((name for name, mode in _check_runs(cmd) if mode == "check"),
2185
+ (`npx axe-core` is `axe-core`). `cwd` adds the repository's declared checks
2186
+ (`repo_checks`); without it only the built-in vocabulary counts."""
2187
+ return next((name for name, mode in _check_runs(cmd, cwd) if mode == "check"),
1925
2188
  None)
1926
2189
 
1927
2190
 
@@ -1994,6 +2257,46 @@ def _closes(cmd, pos, tag, strip, message):
1994
2257
  return None, n
1995
2258
 
1996
2259
 
2260
+ # bash's metacharacters: unquoted, each one ends a word. Any other character -
2261
+ # an escaped or quoted one, a `\r`, a non-breaking space - is part of a word.
2262
+ WORD_BREAKS = " \t\n;&|()<>"
2263
+ # A line continuation: outside single quotes and comments bash deletes both
2264
+ # characters, so the words around it join (`-de\<nl>lete` is `-delete`) and
2265
+ # the word before it goes on (`x\<nl>#` is the word `x#`). After an escaped
2266
+ # backslash (`a\\<nl>`) the newline is a real one and ends the command.
2267
+ CONTINUATION = "\\\n"
2268
+
2269
+
2270
+ def _opens_comment(prev):
2271
+ """True when an unquoted, unescaped `#` opens a comment: only at the start
2272
+ of a word. `prev` is the last character bash kept before it - "" at the
2273
+ start of the text, a non-break for an escaped or quoted one, and the one
2274
+ before a CONTINUATION, which is no character. The one rule every shell
2275
+ reader asks (`_heredocs`, `_shell_lines`, `mask`, `_shell_read`, and the
2276
+ gate's `_shell_words` through `_shell_lines`), so none of them reads a
2277
+ comment where bash runs a command (review of plan 126, round 3h)."""
2278
+ return not prev or prev in WORD_BREAKS
2279
+
2280
+
2281
+ # What before a `(` makes it part of a word: a command or process substitution
2282
+ # or an arithmetic expansion (`$(`, `<(`, `>(`, `$((`) and an array (`x=(`).
2283
+ WORD_PARENS = "$<>="
2284
+
2285
+
2286
+ def _kept(ch, before, parens):
2287
+ """`ch` as the `prev` `_opens_comment` asks of the character after it: a
2288
+ `)` closing a `(` opened right after WORD_PARENS ends no word (`$(:)#x` is
2289
+ one word, so bash runs a command after it), and reads as "_"; a subshell's,
2290
+ an arithmetic command's or a `case` pattern's `)` is a break. `before` is
2291
+ the character kept before `ch` and `parens` the reader's stack of open `(`
2292
+ (review of plan 126, round 3i)."""
2293
+ if ch == "(":
2294
+ parens.append(bool(before) and before in WORD_PARENS)
2295
+ elif ch == ")" and parens and parens.pop():
2296
+ return "_"
2297
+ return ch
2298
+
2299
+
1997
2300
  def _heredocs(cmd):
1998
2301
  """Every heredoc operator in `cmd`, in order, as (operator start, operator
1999
2302
  end, tag, quoted, body start, terminator span or None, safe).
@@ -2015,10 +2318,13 @@ def _heredocs(cmd):
2015
2318
  bash runs - so the reader no longer decides where such a context ends: it
2016
2319
  shows the text. A false refusal of data is the accepted cost
2017
2320
  (tests/bash_vectors.ACCEPTED_REFUSALS)."""
2018
- n, found, pending, i = len(cmd), [], [], 0
2019
- quote, depth, sure, line_start = None, 0, True, 0
2321
+ n, found, pending, i, parens = len(cmd), [], [], 0, []
2322
+ quote, depth, sure, line_start, last = None, 0, True, 0, ""
2020
2323
  while i < n:
2021
2324
  ch = cmd[i]
2325
+ # `last` is the character bash kept before `ch`: a quoted or escaped
2326
+ # one leaves "_", a non-break, unless a branch below says otherwise
2327
+ before, last = last, "_"
2022
2328
  if quote in ("'", "$'"):
2023
2329
  if ch == "\\" and quote == "$'":
2024
2330
  i += 2
@@ -2028,6 +2334,8 @@ def _heredocs(cmd):
2028
2334
  i += 1
2029
2335
  continue
2030
2336
  if ch == "\\":
2337
+ if cmd.startswith(CONTINUATION, i):
2338
+ last = before
2031
2339
  i += 2
2032
2340
  continue
2033
2341
  if quote == '"':
@@ -2039,7 +2347,7 @@ def _heredocs(cmd):
2039
2347
  continue
2040
2348
  if ch == "\n":
2041
2349
  if not pending:
2042
- line_start = i + 1
2350
+ line_start, last = i + 1, ch
2043
2351
  i += 1
2044
2352
  continue
2045
2353
  pos = i + 1
@@ -2053,7 +2361,7 @@ def _heredocs(cmd):
2053
2361
  pos = i + 1
2054
2362
  pending = []
2055
2363
  continue
2056
- if ch == "#" and (i == 0 or cmd[i - 1] in " \t\n;&|()<>"):
2364
+ if ch == "#" and _opens_comment(before):
2057
2365
  end = cmd.find("\n", i)
2058
2366
  i = n if end < 0 else end
2059
2367
  continue
@@ -2078,7 +2386,7 @@ def _heredocs(cmd):
2078
2386
  i += 1
2079
2387
  continue
2080
2388
  if cmd.startswith("<<<", i):
2081
- i += 3
2389
+ i, last = i + 3, "<"
2082
2390
  continue
2083
2391
  if cmd.startswith("<<", i):
2084
2392
  j = i + 2
@@ -2102,7 +2410,7 @@ def _heredocs(cmd):
2102
2410
  depth += 1
2103
2411
  elif ch in ")]}":
2104
2412
  depth -= 1
2105
- i += 1
2413
+ i, last = i + 1, _kept(ch, before, parens)
2106
2414
  found += [(s, e, t, q, n, None, False) for s, e, t, q, _, _ in pending]
2107
2415
  return found
2108
2416
 
@@ -2151,50 +2459,81 @@ def _heredoc_bytes(text):
2151
2459
 
2152
2460
  def mask(text):
2153
2461
  """A shell line with quoted strings, comments and heredoc bodies blanked,
2154
- length and newlines kept, read the way bash reads it: `'...'` takes no
2155
- escape, `$'...'` (an unescaped `$` only) and `"..."` do, a `\\` outside
2156
- quotes escapes one character, and `#` starts a comment only at the start of
2157
- a word. So `https://x`, `a#b`, `src/*.py ... lib/*/`, `'x\\'` and `\\$'x\\'`
2158
- are words, not a comment or an open string that blanks the command after
2159
- them (gate-01). A quote left open blanks from itself to the end - bash runs
2160
- nothing after it - and keeps the reading before it.
2462
+ length kept, read the way bash reads it: `'...'` takes no escape, `$'...'`
2463
+ (an unescaped `$` only) and `"..."` do, a `\\` outside quotes escapes one
2464
+ character, and `#` starts a comment only at the start of a word. So
2465
+ `https://x`, `a#b`, `src/*.py ... lib/*/`, `'x\\'` and `\\$'x\\'` are words,
2466
+ not a comment or an open string that blanks the command after them
2467
+ (gate-01). A quote left open blanks from itself to the end - bash runs
2468
+ nothing after it - and keeps the reading before it. A CONTINUATION bash
2469
+ deletes (outside single quotes, `$'...'` and comments) is moved to its
2470
+ word's start as two spaces, so every rule reads the token bash joins
2471
+ (`tr\\<nl>ue` is `true`, `|\\<nl>|` is `||`) at offsets `joined` keeps
2472
+ (review of plan 126, round 3i).
2161
2473
 
2162
2474
  Memoised per process: a pure function of the text, and one Stop asks it of
2163
2475
  the same ledger details from several readers (both Stop folds, plan 063)."""
2164
- return _mask(str(text or ""))
2476
+ return _mask(str(text or ""))[0]
2477
+
2478
+
2479
+ def joined(text):
2480
+ """The raw `text` with each CONTINUATION moved as `mask` moves it: the text
2481
+ a rule slices at the offsets a masked match proved."""
2482
+ text = str(text or "")
2483
+ return _moved(list(text), _mask(text)[1])
2484
+
2485
+
2486
+ def _moved(out, cuts):
2487
+ for word, at in cuts:
2488
+ out[word + 2:at + 2] = out[word:at]
2489
+ out[word:word + 2] = " "
2490
+ return "".join(out)
2165
2491
 
2166
2492
 
2167
2493
  @functools.lru_cache(maxsize=4096)
2168
2494
  def _mask(text):
2495
+ """(the masked text, the (word start, offset) of each CONTINUATION moved)."""
2169
2496
  text = _blank_heredocs(text)
2170
2497
  body = _heredoc_bytes(text)
2171
2498
  out, i, n, start, prev = list(text), 0, len(text), True, ""
2499
+ parens, word, cuts = [], 0, []
2172
2500
  while i < n:
2173
2501
  ch = text[i]
2174
2502
  if body[i]:
2175
- i, start, prev = i + 1, True, ""
2503
+ i, start, prev, word = i + 1, True, "", i + 1
2176
2504
  continue
2177
2505
  if ch == "\\":
2178
- i, start, prev = i + 2, False, ""
2506
+ # a CONTINUATION is no character: the word state before it goes on
2507
+ if text.startswith(CONTINUATION, i):
2508
+ cuts.append((word, i))
2509
+ else:
2510
+ start, prev = False, ""
2511
+ i += 2
2179
2512
  continue
2180
2513
  if ch in "'\"":
2181
2514
  escapes = ch == '"' or prev == "$"
2182
2515
  end = i + 1
2183
2516
  while end < n and text[end] != ch:
2184
- end += 2 if escapes and text[end] == "\\" else 1
2517
+ if escapes and text[end] == "\\":
2518
+ if ch == '"' and text.startswith(CONTINUATION, end):
2519
+ cuts.append((word, end))
2520
+ end += 1
2521
+ end += 1
2185
2522
  end = min(end + 1, n)
2186
2523
  elif ch == "#" and start:
2187
2524
  end = text.find("\n", i)
2188
2525
  end = n if end < 0 else end
2189
2526
  else:
2190
- start, prev = ch.isspace() or ch in ";&|()<>", ch
2527
+ start, prev = _opens_comment(_kept(ch, prev, parens)), ch
2191
2528
  i += 1
2529
+ if ch in " \t\n":
2530
+ word = i
2192
2531
  continue
2193
2532
  for k in range(i, end):
2194
2533
  if out[k] != "\n":
2195
2534
  out[k] = " "
2196
2535
  i, start, prev = end, False, ""
2197
- return "".join(out)
2536
+ return _moved(out, cuts), tuple(cuts)
2198
2537
 
2199
2538
 
2200
2539
  def _unquoted_backticks(text):
@@ -2215,6 +2554,17 @@ def _unquoted_backticks(text):
2215
2554
  elif ch in "'\"":
2216
2555
  quote = ch
2217
2556
  elif ch == "`":
2557
+ # a `#` right after it, continuations aside, opens no comment
2558
+ # (`_opens_comment` sees `;`): bash reads it mid-word after the
2559
+ # close, and a comment inside the body ends at the close, so it is
2560
+ # escaped here (review of plan 126, round 3i: `` `:`\<nl># ``)
2561
+ j = i + 1
2562
+ while text.startswith(CONTINUATION, j):
2563
+ j += 2
2564
+ if text.startswith("#", j):
2565
+ out.append(";" + text[i + 1:j] + "\\#")
2566
+ i = j + 1
2567
+ continue
2218
2568
  ch = ";"
2219
2569
  out.append(ch)
2220
2570
  i += 1
@@ -2257,28 +2607,32 @@ def _for_shlex(text):
2257
2607
  def _shell_lines(text):
2258
2608
  """`text` split at the newlines that end a command in bash: none inside
2259
2609
  quotes (`'...'`, `"..."`, `$'...'`), each one in a heredoc body or its
2260
- terminator (`_heredoc_bytes`), and a comment - `#` at the start of a word -
2261
- dropped to its line end. A double-quoted string holding a `$( )` or a
2262
- backtick runs a command, so its newlines still split - the reading this had
2263
- before, which keeps that command visible. A quote left open at the end goes
2264
- back to a split at every newline from its line on."""
2610
+ terminator (`_heredoc_bytes`), a CONTINUATION deleted outside single quotes
2611
+ (in `$'...'` it stays, as bash keeps it), and a comment - `#` where
2612
+ `_opens_comment` - dropped to its line end. A double-quoted string holding
2613
+ a `$( )` or a backtick runs a command, so its newlines still split - the
2614
+ reading this had before, which keeps that command visible. A quote left
2615
+ open at the end goes back to a split at every newline from its line on."""
2265
2616
  body = _heredoc_bytes(text)
2266
- lines, cur, quote, start, i, n = [], [], None, True, 0, len(text)
2267
- opened, runs = 0, False
2617
+ lines, cur, quote, prev, i, n = [], [], None, "", 0, len(text)
2618
+ opened, runs, parens = 0, False, []
2268
2619
  while i < n:
2269
2620
  ch = text[i]
2270
2621
  if body[i] or (not quote or runs) and ch in "\r\n":
2271
2622
  if ch in "\r\n":
2272
2623
  lines.append("".join(cur))
2273
- cur, quote, start, runs = [], None, True, False
2274
- i += 2 if text[i:i + 2] == "\r\n" else 1
2624
+ cur, quote, runs = [], None, False
2625
+ step = 2 if text[i:i + 2] == "\r\n" else 1
2626
+ # a lone `\r` is no word break to bash: the split shows more
2627
+ prev, i = text[i + step - 1], i + step
2275
2628
  else:
2276
2629
  cur.append(ch)
2277
2630
  i += 1
2278
2631
  continue
2279
2632
  if quote:
2280
2633
  if ch == "\\" and quote != "'" and i + 1 < n:
2281
- cur.append(text[i:i + 2])
2634
+ if quote != '"' or not text.startswith(CONTINUATION, i):
2635
+ cur.append(text[i:i + 2])
2282
2636
  i += 2
2283
2637
  continue
2284
2638
  if ch == quote[-1]:
@@ -2286,17 +2640,19 @@ def _shell_lines(text):
2286
2640
  elif quote == '"' and (ch == "`" or text.startswith("$(", i)):
2287
2641
  runs = True
2288
2642
  elif ch == "\\" and i + 1 < n:
2289
- cur.append(text[i:i + 2])
2290
- i, start = i + 2, False
2643
+ if not text.startswith(CONTINUATION, i):
2644
+ cur.append(text[i:i + 2])
2645
+ prev = "_"
2646
+ i += 2
2291
2647
  continue
2292
- elif ch == "#" and start:
2648
+ elif ch == "#" and _opens_comment(prev):
2293
2649
  while i < n and text[i] not in "\r\n":
2294
2650
  i += 1
2295
2651
  continue
2296
2652
  elif ch in "'\"":
2297
2653
  quote = "$'" if ch == "'" and cur and cur[-1] == "$" else ch
2298
2654
  opened = len(lines)
2299
- start = not quote and (ch.isspace() or ch in ";&|()<>")
2655
+ prev = "_" if quote or ch in "'\"" else _kept(ch, prev, parens)
2300
2656
  cur.append(ch)
2301
2657
  i += 1
2302
2658
  lines.append("".join(cur))
@@ -2312,8 +2668,9 @@ def _shell_commands(cmd):
2312
2668
  A `$( )` or backtick body is a command of its own, ended by `$)`, and the
2313
2669
  command around it goes on after its close with a `$()` word in its place."""
2314
2670
  out = []
2315
- text = _blank_heredocs(str(cmd or "")).replace("\\\n", " ")
2671
+ text = _blank_heredocs(str(cmd or ""))
2316
2672
  for line in map(_for_shlex, _shell_lines(text)):
2673
+ line = FD_REDIRECT.sub(r"\2", line)
2317
2674
  try:
2318
2675
  lex = shlex.shlex(line, posix=True, punctuation_chars=";&|()<>")
2319
2676
  lex.whitespace_split = True
@@ -2328,10 +2685,9 @@ def _shell_commands(cmd):
2328
2685
  if word == "&" and i < len(words) and REDIRECTION.match(words[i]):
2329
2686
  continue # `&>` is a redirection, not a separator
2330
2687
  if REDIRECTION.match(word):
2331
- # the fd before the operator (`2>`), an `&` with its fd (`>&2`),
2332
- # or the target after it - none of them is an argument
2333
- if cur and cur[-1].isdigit():
2334
- cur.pop()
2688
+ # an `&` with its fd (`>&2`), or the target after it - neither is
2689
+ # an argument. The fd number is cut from the line before shlex
2690
+ # (FD_REDIRECT), because `1<` is an fd and `1 <` a program word.
2335
2691
  after_redir = True
2336
2692
  continue
2337
2693
  if after_redir:
@@ -2339,6 +2695,13 @@ def _shell_commands(cmd):
2339
2695
  continue # `>&2`: the `&` belongs to the operator
2340
2696
  after_redir = False
2341
2697
  continue
2698
+ if word.startswith("((") and cur and cur[-1].endswith("$"):
2699
+ # `$(( ))` is arithmetic: its own words are no commands, though
2700
+ # a `$( )` inside one still is and is entered below
2701
+ cur[-1] += "()"
2702
+ subs.append([cur, 1, True])
2703
+ cur = []
2704
+ continue
2342
2705
  if not (word and word[0] in ";&|()"):
2343
2706
  cur.append(word)
2344
2707
  continue
@@ -2350,21 +2713,32 @@ def _shell_commands(cmd):
2350
2713
  continue
2351
2714
  if subs and piece == "(":
2352
2715
  subs[-1][1] += 1
2716
+ if len(subs[-1]) > 2: # a grouping paren inside arithmetic
2717
+ cur = []
2718
+ continue
2353
2719
  elif subs and piece == ")":
2354
2720
  if not subs[-1][1]:
2355
- if cur:
2721
+ arith = len(subs[-1]) > 2
2722
+ if cur and not arith:
2356
2723
  out.append([cur, "$)"])
2357
2724
  cur = subs.pop()[0]
2358
2725
  continue
2359
2726
  subs[-1][1] -= 1
2727
+ if len(subs[-1]) > 2: # still the arithmetic's own paren
2728
+ cur = []
2729
+ continue
2360
2730
  if cur:
2361
2731
  out.append([cur, piece])
2362
2732
  elif out:
2363
2733
  out[-1][1] += piece
2364
2734
  cur = []
2365
- for seg in [cur] + [outer for outer, _open in reversed(subs)]:
2735
+ # an arithmetic sub holds no command of its own, only any `$( )` in it,
2736
+ # which was emitted when it closed
2737
+ for seg in [cur] + [sub[0] for sub in reversed(subs) if len(sub) < 3]:
2366
2738
  if seg:
2367
2739
  out.append([seg, "\n"])
2740
+ elif out: # the line ended on a close: its newline is still a break
2741
+ out[-1][1] += "\n"
2368
2742
  if out and out[-1][1] == "\n":
2369
2743
  out[-1][1] = ""
2370
2744
  return out
@@ -2374,7 +2748,8 @@ def _shell_segments(cmd):
2374
2748
  """The line's simple commands as word lists, read the way
2375
2749
  `tezgah_context.shell_programs` reads a line - shlex, posix, punctuation
2376
2750
  `;&|()<>` - so quotes and escapes are gone, heredoc bodies are blanked first,
2377
- a continued line is joined, a newline ends a command only where bash ends
2751
+ a continued line is joined (CONTINUATION, in `_shell_lines`, which knows
2752
+ where bash deletes one), a newline ends a command only where bash ends
2378
2753
  one (`_shell_lines`), a `$'...'` is one word (`_for_shlex`), and a `$( )` or
2379
2754
  backtick body is a command of its own (`_shell_commands`). A line shlex
2380
2755
  cannot read is read roughly (`ROUGH_WORDS`) rather than dropped.
@@ -2639,7 +3014,7 @@ def pipe_hides_status(cmd):
2639
3014
  OWNING_SEP = re.compile(r"^[()]*(?:&&|\|&?)[()]*$")
2640
3015
 
2641
3016
 
2642
- def status_hidden(cmd):
3017
+ def status_hidden(cmd, cwd=None):
2643
3018
  """True when the line's exit status is not its check's: a pipe owns it
2644
3019
  (`pipe_hides_status`), or a command after the check does - the check is
2645
3020
  followed by `;` or a newline and more commands, or is sent to the background
@@ -2648,16 +3023,21 @@ def status_hidden(cmd):
2648
3023
  status. A heredoc body and its terminator are data here, and an `exit $?`
2649
3024
  (or a bare `exit`) right after the check ends the line with the check's
2650
3025
  status. Read with `_shell_commands`, each command re-quoted for
2651
- `verify_command`. ponytail: `set -e` is not read, so `set -e; pytest; echo
2652
- done` records as ran - a lost credit, never an invented one."""
3026
+ `verify_command`; `cwd` reads the repository's declared checks
3027
+ (`repo_checks`) as checks too, so `./bin/run-tool x; echo done` is hidden
3028
+ the way `pytest; echo done` is. ponytail: `set -e` is not read, so `set -e;
3029
+ pytest; echo done` records as ran - a lost credit, never an invented one."""
2653
3030
  if pipe_hides_status(cmd):
2654
3031
  return True
2655
3032
  cmd = str(cmd or "")
2656
3033
  body = _heredoc_bytes(cmd)
2657
3034
  cmds = _shell_commands("".join(" " if b and ch != "\n" else ch
2658
3035
  for ch, b in zip(cmd, body)))
3036
+ prefixes = repo_checks(cwd)
2659
3037
  for i, (words, _sep) in enumerate(cmds):
2660
- if not verify_command(shlex.join(words)):
3038
+ line = shlex.join(words)
3039
+ if not (verify_command(line) or any(
3040
+ mode == "check" for _n, mode in _repo_check_runs(line, prefixes))):
2661
3041
  continue
2662
3042
  for k in range(i, len(cmds) - 1):
2663
3043
  if OWNING_SEP.match(cmds[k][1]):
@@ -2685,9 +3065,10 @@ def piped_check(cmd):
2685
3065
  c = mask(cmd)
2686
3066
  if PIPEFAIL.match(c):
2687
3067
  return None
2688
- raw = str(cmd or "")
3068
+ raw = joined(cmd)
2689
3069
  pos, check = 0, None
2690
- # mask keeps every offset, so a stage's span in `c` is its text in `raw`
3070
+ # mask keeps every offset of `joined`, so a stage's span in `c` is its text
3071
+ # in `raw`
2691
3072
  for part in re.split(r"(&&|\|\||;|\n|\|)", c):
2692
3073
  if part in ("&&", "||", ";", "\n"):
2693
3074
  check = None
@@ -2793,14 +3174,15 @@ def shortcut_edit(inp):
2793
3174
  return None
2794
3175
 
2795
3176
 
2796
- def classify(tool, inp):
2797
- """The evidence kind for a tool call, or None. Shared by the host hooks."""
3177
+ def classify(tool, inp, cwd=None):
3178
+ """The evidence kind for a tool call, or None. Shared by the host hooks.
3179
+ `cwd` lets the repository's declared checks (`repo_checks`) count."""
2798
3180
  t = str(tool or "").lower()
2799
3181
  if t in WRITE_TOOLS:
2800
3182
  return "edit"
2801
3183
  if t in BASH_TOOLS:
2802
3184
  cmd = inp.get("command") or inp.get("cmd") or ""
2803
- return "verify" if verify_command(cmd) else "run"
3185
+ return "verify" if verify_command(cmd, cwd) else "run"
2804
3186
  return None
2805
3187
 
2806
3188
 
@@ -2847,7 +3229,7 @@ def classify(tool, inp):
2847
3229
  # host can put in front of the model; the taint notice that flags a later effect
2848
3230
  # in such a turn is the untrusted module's own, not the gate's.
2849
3231
  WEB_TOOLS = ("web_search", "websearch", "web_fetch", "webfetch", "fetch",
2850
- "browser", "browse")
3232
+ "browser", "browse", "read_web_page")
2851
3233
  MCP_TOOL = re.compile(r"^mcp__", re.I)
2852
3234
  # The verb classes of an MCP tool, read off its name: what the server is asked to
2853
3235
  # do picks which of the gate's existing content rules read the payload
@@ -2990,7 +3372,12 @@ TIER_LOCAL_ARGS = ("-h", "--help", "--use")
2990
3372
  UNTRUSTED_CHANNEL = {"web": "a web result", "mcp": "an MCP server",
2991
3373
  "network": "a network read",
2992
3374
  "tier": "an external model answer",
2993
- "subagent": "a subagent's report"}
3375
+ "subagent": "a subagent's report",
3376
+ "repo": "a tool this repository declares untrusted"}
3377
+ # The sixth channel is the repository's own: its manifest's `untrusted` list
3378
+ # names programs whose output is someone else's text - a scanner reading a
3379
+ # target, a fetcher this file cannot know (`tezgah_extension.untrusted_programs`).
3380
+ REPO_CHANNEL = "repo"
2994
3381
  # A delegate's report is the fifth channel, and the one no host counted: what a
2995
3382
  # subagent hands back is prose this session did not write and cannot vouch for,
2996
3383
  # read by a parent that never watched it being produced. Spelled the way every
@@ -3018,14 +3405,35 @@ def _tier_read(cmd):
3018
3405
  return False
3019
3406
 
3020
3407
 
3021
- def untrusted_source(tool, inp):
3408
+ def _repo_untrusted(cmd, cwd):
3409
+ """True when this shell line runs a program the repository at `cwd` (None:
3410
+ the hook process's directory) declares untrusted. Read at a command
3411
+ position by `shell_programs`, so a mention in an argument is not a run.
3412
+ Imported here for `_tier_read`'s reason; a broken extension, or a process
3413
+ directory that is gone, costs the channel, never the call."""
3414
+ try:
3415
+ import tezgah_extension
3416
+ from tezgah_context import shell_programs
3417
+ names = tezgah_extension.untrusted_programs(cwd or os.getcwd())
3418
+ return bool(names) and bool(names.intersection(shell_programs(cmd)))
3419
+ except Exception:
3420
+ return False
3421
+
3422
+
3423
+ def untrusted_source(tool, inp, cwd=None):
3022
3424
  """The untrusted channel this call's result came through, or None.
3023
3425
 
3024
3426
  Decided from the call, because that is all a PostToolUse hook sees: the
3025
3427
  tool's name for the two named channels, the masked command text for a shell
3026
3428
  read that left the machine. None means the result is the user's or this
3027
3429
  workspace's, which is the normal case - the label names the exception, so it
3028
- never becomes noise the model learns to skip."""
3430
+ never becomes noise the model learns to skip.
3431
+
3432
+ `cwd` is where the repository's `untrusted` declaration is read from; None
3433
+ reads the call's own `cwd`/`workdir`, else the hook process's directory.
3434
+ ponytail: the callers pass no cwd today, so a host that runs its hook
3435
+ elsewhere than the session reads no declaration - a missed label, never a
3436
+ false one."""
3029
3437
  name = str(tool or "").strip().lower()
3030
3438
  if MCP_TOOL.match(name):
3031
3439
  return "mcp"
@@ -3039,6 +3447,9 @@ def untrusted_source(tool, inp):
3039
3447
  return "network"
3040
3448
  if name in BASH_TOOLS and _tier_read(cmd):
3041
3449
  return "tier"
3450
+ if name in BASH_TOOLS and cmd and _repo_untrusted(
3451
+ cmd, cwd or inp.get("cwd") or inp.get("workdir")):
3452
+ return REPO_CHANNEL
3042
3453
  return None
3043
3454
 
3044
3455
 
@@ -3380,9 +3791,9 @@ def note_tool(session_id, tool, inp, failed=None, *, interrupted=False,
3380
3791
  workspace=root_for(cwd) if cwd else None)
3381
3792
  return
3382
3793
  cmd = str(inp.get("command") or inp.get("cmd") or "")
3383
- kind = classify(tool, inp)
3794
+ kind = classify(tool, inp, cwd)
3384
3795
  if kind == "verify":
3385
- if failed is None or status_hidden(cmd) or (empty_run and not failed):
3796
+ if failed is None or status_hidden(cmd, cwd) or (empty_run and not failed):
3386
3797
  kind = "verify"
3387
3798
  else:
3388
3799
  kind = "verify_fail" if failed else "verify_ok"
@@ -3656,26 +4067,67 @@ NEGATION_BEFORE = re.compile(r"(?:%s)(?:\s+\S+){0,2}\s+\Z" % alts("negation_befo
3656
4067
  QUESTION_PARTICLE = re.compile(r"\s*(?:%s)\b" % alts("question_particle"), re.I)
3657
4068
  CLAUSE_END = re.compile(r"[.!?\n,;:\u2014\u2013()]|\b(?:%s)\b" % alts("clause_join"),
3658
4069
  re.I)
4070
+ # The first-person claim that a command or a check was run ("test ettim", "i
4071
+ # ran the suite"): the one claim a read-only turn cannot have earned, read by
4072
+ # the `plan claim` class. Narrower than DONE/VERIFIED on purpose - a plan
4073
+ # states its acceptance in those words (plan 142, review round 2).
4074
+ RAN_IT = re.compile(alts("ask", "ran_it"), re.I)
3659
4075
 
3660
4076
 
3661
- def asserted_claims(text):
3662
- """DONE/VERIFIED, with a claim word inside a question or under a negation of its
3663
- own clause not counted. Read by `_stop_block` on the no-work path and by
3664
- `stop_reason` for the claim row; a turn that did work is still judged on its
3665
- rows. ponytail: a window of words, not a parser."""
4077
+ def _asserted(pattern, text):
4078
+ """The first match of `pattern` in `text` that is neither inside a question
4079
+ nor under a negation of its own clause, else None. ponytail: a window of
4080
+ words, not a parser."""
3666
4081
  t = str(text or "")
4082
+ for m in pattern.finditer(t):
4083
+ before = CLAUSE_END.split(t[max(0, m.start() - 80):m.start()])[-1]
4084
+ end = CLAUSE_END.search(t, m.end())
4085
+ asked = (QUESTION_PARTICLE.match(t, m.end())
4086
+ or (end and end.group(0) == "?"))
4087
+ if not (NEGATION_BEFORE.search(before) or asked):
4088
+ return m
4089
+ return None
3667
4090
 
3668
- def said(pattern):
3669
- for m in pattern.finditer(t):
3670
- before = CLAUSE_END.split(t[max(0, m.start() - 80):m.start()])[-1]
3671
- end = CLAUSE_END.search(t, m.end())
3672
- asked = (QUESTION_PARTICLE.match(t, m.end())
3673
- or (end and end.group(0) == "?"))
3674
- if not (NEGATION_BEFORE.search(before) or asked):
3675
- return True
3676
- return False
3677
4091
 
3678
- return said(DONE), said(VERIFIED)
4092
+ def asserted_claims(text):
4093
+ """DONE/VERIFIED, with a claim word inside a question or under a negation of
4094
+ its own clause not counted (`_asserted`). Read by `_stop_block` on the
4095
+ no-work path and by `stop_reason` for the claim row; a turn that did work is
4096
+ still judged on its rows."""
4097
+ return bool(_asserted(DONE, text)), bool(_asserted(VERIFIED, text))
4098
+
4099
+
4100
+ def reply_prose(value):
4101
+ """The reply's prose, whatever the host handed the Stop rule (plan 143).
4102
+
4103
+ omp's `session_stop` carries `last_assistant_message` as the message
4104
+ OBJECT its `getLastAssistantMessage()` returns, not as text, and the bridge
4105
+ passed it through: the rule then judged `str(dict)` - one line, no list
4106
+ items, the prose diluted by the object's own fields. Measured on the
4107
+ owner's ledger, 1643 of 1656 omp `shape` rows of 45 days carried
4108
+ `lines: 1`, against 209 of 231 multi-line on every other host, so every
4109
+ line-shaped class (the list cap, the residual sections, the question the
4110
+ reply ends on, the handed step) read a text that had no lines.
4111
+
4112
+ A string passes through. A message object yields its `text` blocks, joined
4113
+ the way the model wrote them; a thinking or tool block is not the reply.
4114
+ Anything else is no reply at all, which is what the rule already does with
4115
+ a missing one: it never becomes a judgement on a repr.
4116
+ """
4117
+ if isinstance(value, str):
4118
+ return value
4119
+ if isinstance(value, dict):
4120
+ blocks = value.get("content")
4121
+ if isinstance(blocks, str):
4122
+ return blocks
4123
+ if isinstance(blocks, list):
4124
+ return "\n".join(
4125
+ block["text"] for block in blocks
4126
+ if isinstance(block, dict) and block.get("type") == "text"
4127
+ and isinstance(block.get("text"), str)).strip()
4128
+ if isinstance(value.get("text"), str):
4129
+ return value["text"]
4130
+ return ""
3679
4131
 
3680
4132
 
3681
4133
  def passing_check(entry):
@@ -3692,7 +4144,15 @@ def passing_check(entry):
3692
4144
  return False
3693
4145
  if entry.get(ORPHAN) or entry.get("exit") != 0 or entry.get("out_bytes") == 0:
3694
4146
  return False
3695
- return not status_hidden(entry.get("detail"))
4147
+ # The row carries no cwd: its one location is `repo`, which `_check_repo`
4148
+ # sets only when the call named where it ran (a `cwd`/`workdir` argument, a
4149
+ # leading `cd X`, path arguments in one repository) and leaves None
4150
+ # otherwise. So this re-read sees the repository's declared checks
4151
+ # (`repo_checks`) only on such a row; on the rest it reads the built-in
4152
+ # vocabulary alone. The reading that holds `./bin/run-tool x; echo done` to
4153
+ # `verify` is `note_tool`'s, with the call's own cwd - this one guards a
4154
+ # `verify_ok` row another writer appended, as far as its `repo` reaches.
4155
+ return not status_hidden(entry.get("detail"), entry.get("repo"))
3696
4156
 
3697
4157
 
3698
4158
  def _changed_write(row):
@@ -3949,7 +4409,26 @@ def _ui_evidence(rows):
3949
4409
  return write, proof
3950
4410
 
3951
4411
 
3952
- def _design_evidence(rows):
4412
+ def _slop_row_holds(row, cwd):
4413
+ """True when a `tezgah-design slop` row is evidence for THIS repository.
4414
+
4415
+ The verdict is a file, so the command alone is not enough: a scan pointed at
4416
+ another tree (`--repo <fixture>`) would pass a component edit it never looked
4417
+ at (review of plan 133, finding 5). The repo the row names, or the row's own
4418
+ cwd when it names none, has to be the session's repository."""
4419
+ detail = mask(str(row.get("detail") or ""))
4420
+ named = SLOP_REPO.search(detail)
4421
+ target = named.group(1).strip("'\"") if named else ""
4422
+ base = os.path.realpath(os.path.abspath(cwd or os.getcwd()))
4423
+ root = _toplevel(base) or base
4424
+ want = os.path.realpath(root)
4425
+ if not target or target in (".", "./"):
4426
+ return True
4427
+ path = os.path.realpath(os.path.join(root, os.path.expanduser(target)))
4428
+ return path == want or path.startswith(want + os.sep)
4429
+
4430
+
4431
+ def _design_evidence(rows, cwd=None):
3953
4432
  """(component, check) as row indices for the design-contract half of the UI
3954
4433
  rule, -1 for either when the row is not there.
3955
4434
 
@@ -3966,6 +4445,8 @@ def _design_evidence(rows):
3966
4445
  if _ui_write(row) and DESIGN_COMPONENT.search(detail):
3967
4446
  component = i
3968
4447
  if passing_check(row) and DESIGN_CHECK.search(detail):
4448
+ if DESIGN_SLOP.search(detail) and not _slop_row_holds(row, cwd):
4449
+ continue
3969
4450
  check = i
3970
4451
  return component, check
3971
4452
 
@@ -4409,10 +4890,14 @@ def _record_only(refused, turns, after_block, cls):
4409
4890
 
4410
4891
 
4411
4892
  def stop_reason(text, session_id, cwd=None, after_block=0,
4412
- subagent=False, agent=None, parent=None):
4893
+ subagent=False, agent=None, parent=None, mode=None):
4413
4894
  """Why this turn must not end yet, or None. Used by the Stop hooks (Claude,
4414
4895
  Codex, omp and Cursor, which share the payload fields and the block envelope).
4415
4896
  `cwd` is the session's directory, read for the repo's `.no-adhd` mark.
4897
+ `mode` is the host's permission mode for this turn (Claude, Command Code
4898
+ and Qwen send `permission_mode`; omp's extension sends its plan state as
4899
+ one): a read-only one (`read_only_turn`) exempts the approval question the
4900
+ mode asks for and refuses the done/tested claim it cannot have earned.
4416
4901
 
4417
4902
  The verdict is read off the current turn's rows (`turn_rows`) and not the
4418
4903
  whole ledger: the file grows with the session, the handler runs once per
@@ -4458,15 +4943,19 @@ def stop_reason(text, session_id, cwd=None, after_block=0,
4458
4943
  the child keeps a ledger of its own (omp's task sessions); the child's own
4459
4944
  rows still judge it. Both return None. A reply counts as
4460
4945
  claiming only with `asserted_claims`: "Testler geçti mi?" leaves no
4461
- `claim ok` row."""
4946
+ `claim ok` row. `text` is read through `reply_prose`, so a host that hands
4947
+ over a message object rather than its text is judged on the prose (plan
4948
+ 143)."""
4949
+ text = reply_prose(text)
4462
4950
  rows, turns = turn_rows(session_id, turns=True)
4463
4951
  chain, rows = (_turn_refusals(session_id, rows) if turns
4464
4952
  else (list(filter(_blocked, rows)), rows))
4465
4953
  refused = [str(entry.get("detail"))[len("blocked: "):] for entry in chain]
4466
4954
  if after_block and not subagent and not refused:
4467
4955
  return None
4956
+ read_only = read_only_turn(mode)
4468
4957
  cls, reason = _stop_block(text, session_id, rows=rows, cwd=cwd,
4469
- shape=not subagent)
4958
+ shape=not subagent, read_only=read_only)
4470
4959
  record_only = subagent or bool(
4471
4960
  (refused if turns else after_block)
4472
4961
  and (not reason or _record_only(refused, turns, after_block, cls)))
@@ -4474,7 +4963,11 @@ def stop_reason(text, session_id, cwd=None, after_block=0,
4474
4963
  # Keyed like the claim row, so Cursor's re-run of the handler on a follow-up
4475
4964
  # does not count the same reply twice; above the early return, so a reply
4476
4965
  # with no claim vocabulary still leaves its row.
4966
+ # `mode` rides every row of a read-only turn: the verdict depended on it,
4967
+ # and a replay of the ledger reads it back (`tezgah_replay._replay_one`).
4477
4968
  shape = reply_shape(text)
4969
+ if read_only:
4970
+ shape = dict(shape, mode=str(mode).strip().lower())
4478
4971
  if not record_only and not any(entry.get("kind") == "shape"
4479
4972
  and entry.get("id") == key for entry in rows):
4480
4973
  note(session_id, "shape", ",".join(shape_flags(text)) or "ok", id=key,
@@ -4578,6 +5071,12 @@ GIT_BOOKKEEPING = frozenset((
4578
5071
  # is work - an unknown program owes the check, a known one never excuses a write.
4579
5072
  GIT_READS = GIT_BOOKKEEPING - {"add", "commit", "push", "fetch", "branch", "tag",
4580
5073
  "remote"}
5074
+ # `set`'s flags that only change when the shell stops or what it traces: the
5075
+ # `set -o pipefail;` prefix the contract asks a check line for. Anything else
5076
+ # is no read: `-k` and `+o interactive-comments` change how the rest of the
5077
+ # line runs, a plain word sets the positional parameters.
5078
+ SET_FLAGS = re.compile(r"[-+][euxvo]+")
5079
+ SET_NAMES = frozenset(("pipefail", "errexit", "nounset", "xtrace", "verbose"))
4581
5080
  # ssh's options that take the next word, so the host is read after them
4582
5081
  SSH_VALUE_OPTS = frozenset(("-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J",
4583
5082
  "-L", "-l", "-m", "-O", "-o", "-p", "-Q", "-R", "-S",
@@ -4607,6 +5106,7 @@ SUBCOMMAND_READS = {"gh": (("pr", "view"), ("pr", "list"), ("issue", "view"),
4607
5106
  # all (review F6), as a pattern read at an argument's start: git's `--output`
4608
5107
  # and external diff/textconv drivers, ripgrep's `--pre`, find's actions, a
4609
5108
  # short-option cluster holding sort's `-o`, sed's `-i`/`-f`, awk's `-i`/`-f`,
5109
+ # printf's `-v` (it assigns a variable, PATH included: plan 126),
4610
5110
  # and curl's writes to a file and non-GET requests. `tree` and `file` left the
4611
5111
  # list instead of growing one here: tree writes through clustered short options
4612
5112
  # (`-fo out`) and `-R -H`, file through `-C` (review N4).
@@ -4615,6 +5115,7 @@ WRITING_OPTIONS = {
4615
5115
  "rg": re.compile(r"--pre"),
4616
5116
  "find": re.compile(r"-(?:delete|exec|execdir|ok|okdir|fprint0?|fprintf|fls)$"),
4617
5117
  "sort": re.compile(r"-[^-]*o|--output"),
5118
+ "printf": re.compile(r"-v"),
4618
5119
  "sed": re.compile(r"-[^-]*[if]|--(?:in-place|file)"),
4619
5120
  "awk": re.compile(r"-[^-]*[if]|--(?:include|file|load)"),
4620
5121
  "gawk": re.compile(r"-[^-]*[if]|--(?:include|file|load|inplace)"),
@@ -4626,9 +5127,32 @@ WRITING_OPTIONS = {
4626
5127
  GIT_CONFIG_OPTS = ("-c", "--config-env", "--exec-path")
4627
5128
  # an fd duplication or a discard: a redirect that writes no file
4628
5129
  HARMLESS_REDIRECT = re.compile(r"\d*>&\d+|\d*>>?\s*/dev/null\b")
4629
-
4630
-
4631
- def _shell_read(cmd):
5130
+ # The compound-command words `loops=True` reads (plan 126): an opener is no
5131
+ # program (the command after it is: `do cat "$f"`), a closer runs nothing
5132
+ SHELL_OPENERS = frozenset(("if", "then", "elif", "else", "do", "while", "until",
5133
+ "!", "{"))
5134
+ SHELL_CLOSERS = frozenset(("done", "fi", "}"))
5135
+ # a loop variable no shell reads: one lowercase letter. A longer lowercase
5136
+ # name can be one zsh ties to a setting (`path` is PATH, and `cdpath`, `fpath`,
5137
+ # `manpath`, `module_path`, `psvar`, `watch`, `histchars`, `prompt`, `status`
5138
+ # and the modules' `options`, `mapfile`... are its own), and its list grows
5139
+ # with every loaded module
5140
+ LOOP_NAME = re.compile(r"[a-z]")
5141
+ # A word this reader passes on: bash expands neither a brace list nor an
5142
+ # operator form to anything it can read. `{-delete,-print}` is two words,
5143
+ # `${x:--delete}` an option, `$[x]`, `$((x))` and `${a[x]}` evaluate x as
5144
+ # arithmetic, which runs a `$( )` in its value. Read on `_shell_read`'s masks.
5145
+ BRACE_EXPANSION = re.compile(r"\{[^\s;|&<>()]*(?:,|\.\.)[^\s;|&<>()]*\}")
5146
+ PLAIN_DOLLAR = re.compile(r"\$(?:[A-Za-z_]\w*|\{(?:[A-Za-z_]\w*|[0-9@*#?$!-])\}|"
5147
+ r"[0-9@*#?$!-]|(?![\[{(]))")
5148
+ # a function's arguments: values this reader never sees
5149
+ POSITIONAL = re.compile(r"\$\{?[0-9@*#]")
5150
+ # the readers no option of which writes or runs: the only ones a value this
5151
+ # reader never sees (a loop word, a substitution's output) may reach
5152
+ OPTION_FREE = READ_ONLY_PROGRAMS - set(WRITING_OPTIONS)
5153
+
5154
+
5155
+ def _shell_read(cmd, subst=False):
4632
5156
  """(effect, text) for `cmd`: whether bash would run a command or process
4633
5157
  substitution, or redirect into a file, anywhere in it - or this reader
4634
5158
  cannot tell - and the command with what is not a command blanked for
@@ -4645,7 +5169,16 @@ def _shell_read(cmd):
4645
5169
 
4646
5170
  Fails closed (effect True): an unquoted heredoc expands `$(` in its body,
4647
5171
  an unterminated one or an empty delimiter cannot be read, and a quote still
4648
- open at the end means the reading lost sync with bash (review N1)."""
5172
+ open at the end means the reading lost sync with bash (review N1).
5173
+
5174
+ Also an effect: a brace expansion (unquoted `{a,b}`), a function definition
5175
+ (`name()`: its body runs with arguments read nowhere), and a word whose `$`
5176
+ is more than a plain `$name`/`${name}` or holds a `[` (`PLAIN_DOLLAR`;
5177
+ double quotes still expand it, single quotes do not).
5178
+
5179
+ `subst=True` reads an unquoted `$(` as no effect of its own: its body is a
5180
+ command `_shell_segments` returns, and its redirects are in the bare text;
5181
+ inside double quotes it stays one word that reader never splits."""
4649
5182
  n, out = len(cmd), list(cmd)
4650
5183
 
4651
5184
  def blank(a, b):
@@ -4659,7 +5192,8 @@ def _shell_read(cmd):
4659
5192
  blank(start, stop)
4660
5193
  blank(body, term[1])
4661
5194
  cmd = "".join(out)
4662
- bare, quote, i = [], None, 0
5195
+ bare, lit, dq, quote, i = [], [], [], None, 0
5196
+ kept, parens = "", [] # lit's last character as `_opens_comment` asks it
4663
5197
  while i < n:
4664
5198
  ch = cmd[i]
4665
5199
  if quote == "'":
@@ -4670,14 +5204,25 @@ def _shell_read(cmd):
4670
5204
  elif ch == "'":
4671
5205
  quote = None
4672
5206
  elif ch == "\\":
5207
+ # a CONTINUATION: bash deletes both, so `$\<nl>1` is `$1` and
5208
+ # nothing is left in the word
5209
+ if cmd.startswith(CONTINUATION, i):
5210
+ i += 2
5211
+ continue
4673
5212
  i += 2
4674
5213
  bare.append(" ")
5214
+ lit.append("_")
5215
+ dq.append("_")
5216
+ kept = "_"
4675
5217
  continue
4676
- elif ch == "`" or cmd.startswith("$(", i):
5218
+ elif ch == "`" or (cmd.startswith("$(", i) and (quote or not subst)):
4677
5219
  return True, ""
4678
5220
  elif quote == '"':
4679
5221
  quote = None if ch == '"' else quote
4680
- elif ch == "#" and (i == 0 or cmd[i - 1] in " \t\n;&|()<>"):
5222
+ # a `#` opens a comment only at a word's start (`_opens_comment`): after
5223
+ # an escaped or quoted character (`x\ #...`) or a substitution's `)`
5224
+ # (`_kept`) it is part of the word
5225
+ elif ch == "#" and _opens_comment(kept):
4681
5226
  end = cmd.find("\n", i)
4682
5227
  end = n if end < 0 else end
4683
5228
  blank(i, end)
@@ -4690,19 +5235,53 @@ def _shell_read(cmd):
4690
5235
  quote = ch
4691
5236
  else:
4692
5237
  bare.append(ch)
5238
+ lit.append(ch)
5239
+ dq.append(ch)
5240
+ kept = _kept(ch, kept, parens)
4693
5241
  i += 1
4694
5242
  continue
4695
5243
  bare.append(" ")
5244
+ # `lit` keeps only unquoted text, `dq` double-quoted text too
5245
+ lit.append("_")
5246
+ kept = "_"
5247
+ dq.append(ch if quote == '"' or ch == '"' and not quote else "_")
4696
5248
  i += 1
4697
5249
  if quote:
4698
5250
  return True, ""
5251
+ lit, dq = "".join(lit), "".join(dq)
5252
+ if BRACE_EXPANSION.search(lit) or re.search(r"\(\s*\)", lit) or "$((" in dq:
5253
+ return True, ""
5254
+ for word in re.split(r"[\s;&|()<>]+", dq):
5255
+ if "$" in word and ("[" in word or "$" in PLAIN_DOLLAR.sub("", word)):
5256
+ return True, ""
5257
+ # a positional parameter is a value no read here sees: where loops read
5258
+ # (`subst`), a `$1` the shell expands - unquoted or double-quoted, never one
5259
+ # inside single quotes, a comment or a heredoc body - makes the command work
5260
+ if subst and POSITIONAL.search(dq):
5261
+ return True, ""
4699
5262
  text = HARMLESS_REDIRECT.sub(" ", "".join(bare))
4700
5263
  # every `<` left is an input redirect or a here-string, both reads; blanking
4701
5264
  # them keeps `_shell_segments`' own heredoc pass from reading one again
4702
5265
  return ">" in text or "<(" in text, "".join(out).replace("<", " ")
4703
5266
 
4704
5267
 
4705
- def _bookkeeping_command(cmd, git=GIT_BOOKKEEPING):
5268
+ def _set_options(args):
5269
+ """True when `set`'s words are only SET_FLAGS, each `o` in them followed by
5270
+ a SET_NAMES name or by nothing (`set -o` prints the options). A bare `set`
5271
+ prints every variable, and stays work like any other unlisted shape."""
5272
+ j = 0
5273
+ while j < len(args):
5274
+ if not SET_FLAGS.fullmatch(args[j]):
5275
+ return False
5276
+ for _ in range(args[j].count("o")):
5277
+ j += 1
5278
+ if j < len(args) and args[j] not in SET_NAMES:
5279
+ return False
5280
+ j += 1
5281
+ return bool(args)
5282
+
5283
+
5284
+ def _bookkeeping_command(cmd, git=GIT_BOOKKEEPING, loops=False):
4706
5285
  """True when every simple command of `cmd` reads, or runs a git subcommand
4707
5286
  in `git` (VCS bookkeeping that changes no working-tree file by default,
4708
5287
  GIT_READS for a pure read). A redirect, a substitution, an env assignment
@@ -4713,21 +5292,40 @@ def _bookkeeping_command(cmd, git=GIT_BOOKKEEPING):
4713
5292
  `codegraph` with a read verb (`SUBCOMMAND_READS`). `sqlite3` reads with a
4714
5293
  read-only open (the option, or `mode=ro` in the database URI) and no
4715
5294
  writing dot-command or stdin (`SQLITE_WRITE`), `ssh` with a remote command
4716
- that is itself one of these reads.
5295
+ that is itself one of these reads, `set` with shell options only
5296
+ (`_set_options`).
5297
+
5298
+ `loops=True` (plan 126) also reads a `for NAME in WORDS` loop, the
5299
+ `if`/`while`/`{` openers and closers around reads, and an unquoted `$( )`
5300
+ whose body reads. The values those bind - a loop word, a substitution's
5301
+ output, a positional parameter (`POSITIONAL`) - are text this reader never
5302
+ sees, so in such a command a `$` word may only reach a reader no option of
5303
+ which writes (`OPTION_FREE`), and the loop variable is one letter no shell
5304
+ reads (`LOOP_NAME`).
4717
5305
 
4718
5306
  ponytail: `git commit` and `git push` run the repository's hooks, and a
4719
5307
  pre-commit autofixer can rewrite files the row never names. They stay on
4720
5308
  the list because refusing every commit was the measured cost (audit CHAT-04);
4721
5309
  a hook that rewrites the tree is invisible here."""
4722
5310
  raw = str(cmd or "").replace(FAILED_MARK, "")
4723
- effect, text = _shell_read(raw) if raw.strip() else (True, "")
5311
+ effect, text = _shell_read(raw, loops) if raw.strip() else (True, "")
4724
5312
  if effect:
4725
5313
  return False
4726
5314
  segs = _shell_segments(text)
5315
+ unseen = loops and any("for" in w or any("$(" in a for a in w) for w in segs)
4727
5316
  for words in segs:
4728
5317
  i = 0
4729
- while i < len(words) and words[i] in GIT_WRAPPER:
5318
+ while i < len(words) and (words[i] in GIT_WRAPPER
5319
+ or loops and words[i] in SHELL_OPENERS):
4730
5320
  i += 1
5321
+ if loops and all(w in SHELL_OPENERS for w in words[:i]) and (
5322
+ i == len(words) or words[i:] == words[-1:] and words[i] in SHELL_CLOSERS):
5323
+ continue
5324
+ if loops and words[i:i + 1] == ["for"]:
5325
+ if not (words[i + 1:] and LOOP_NAME.fullmatch(words[i + 1])
5326
+ and words[i + 2:i + 3] in ([], ["in"])):
5327
+ return False
5328
+ continue
4731
5329
  if i >= len(words) or ENV_WORD.match(words[i]):
4732
5330
  return False
4733
5331
  # a program named by a path is whatever that file is, not the
@@ -4735,6 +5333,8 @@ def _bookkeeping_command(cmd, git=GIT_BOOKKEEPING):
4735
5333
  if "/" in words[i]:
4736
5334
  return False
4737
5335
  program, args = words[i], words[i + 1:]
5336
+ if unseen and program not in OPTION_FREE and any("$" in a for a in args):
5337
+ return False
4738
5338
  writes = WRITING_OPTIONS.get(program)
4739
5339
  if writes and any(writes.match(a) for a in args):
4740
5340
  return False
@@ -4745,6 +5345,10 @@ def _bookkeeping_command(cmd, git=GIT_BOOKKEEPING):
4745
5345
  if any(SCRIPT_WRITE[program].search(a) for a in plain):
4746
5346
  return False
4747
5347
  continue
5348
+ if program == "set":
5349
+ if not _set_options(args):
5350
+ return False
5351
+ continue
4748
5352
  if program == "uniq":
4749
5353
  # `uniq IN OUT` writes its second operand
4750
5354
  if len(plain) > 1:
@@ -4786,7 +5390,7 @@ def _bookkeeping_command(cmd, git=GIT_BOOKKEEPING):
4786
5390
  return bool(segs)
4787
5391
 
4788
5392
 
4789
- def _bookkeeping_turn(rows, git=GIT_BOOKKEEPING):
5393
+ def _bookkeeping_turn(rows, git=GIT_BOOKKEEPING, loops=False):
4790
5394
  """True when every work row in `rows` is a shell call that read or ran a
4791
5395
  git subcommand in `git` (`_bookkeeping_command`) and was not seen to
4792
5396
  change a file.
@@ -4800,7 +5404,7 @@ def _bookkeeping_turn(rows, git=GIT_BOOKKEEPING):
4800
5404
  r.get("kind") == "run" and not _change_row(r)
4801
5405
  and len(str(r.get("detail") or "")) < DETAIL_MAX
4802
5406
  and "[redacted:" not in str(r.get("detail") or "")
4803
- and _bookkeeping_command(r.get("detail"), git) for r in steps)
5407
+ and _bookkeeping_command(r.get("detail"), git, loops) for r in steps)
4804
5408
 
4805
5409
 
4806
5410
  def _settled(rows):
@@ -4814,7 +5418,8 @@ def _settled(rows):
4814
5418
  return not after or _bookkeeping_turn(after)
4815
5419
 
4816
5420
 
4817
- def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
5421
+ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None,
5422
+ read_only=False):
4818
5423
  """stop_reason's decision as (reason class, block text), without the ledger
4819
5424
  side effect. The class names the branch that refused the turn; the text is
4820
5425
  what the host shows the model.
@@ -4871,11 +5476,18 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4871
5476
  `fold` is the evidence fold the selector below calls. None, the default,
4872
5477
  is the temporal spec's (`tezgah_stopspec.fold`: the class from its formulas,
4873
5478
  the text from `_evidence_text`), and that outer call fills the text's
4874
- placeholders once (`_said`)."""
5479
+ placeholders once (`_said`).
5480
+
5481
+ `read_only` is the host's word that this turn ran in a plan mode
5482
+ (`read_only_turn`), where no command and no write was possible. Two classes
5483
+ read it: `plan claim` below, which refuses a done/tested claim such a turn
5484
+ cannot have behind it, and the `needless ask` exemption in
5485
+ `_residual_block`, since the approval question is what the mode asks for."""
4875
5486
  if fold is None:
4876
5487
  # imported here: tezgah_stopspec imports this module at its top
4877
5488
  import tezgah_stopspec
4878
- verdict = _stop_block(text, session_id, rows, cwd, shape, tezgah_stopspec.fold)
5489
+ verdict = _stop_block(text, session_id, rows, cwd, shape,
5490
+ tezgah_stopspec.fold, read_only)
4879
5491
  return (verdict[0], verdict[1] and _said(verdict[1]))
4880
5492
  t = str(text or "")
4881
5493
  shaped = _shape_block(t, cwd) if shape else (None, None)
@@ -4889,6 +5501,19 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4889
5501
  return ("evidence tampered", TAMPERED)
4890
5502
  if (done or verified) and any(r.get(ORPHAN) for r in rows):
4891
5503
  return ("evidence tampered", ORPHANED)
5504
+ # A read-only turn (the host's plan mode) ran no check, so a reply that says
5505
+ # it ran one says what cannot have happened - unless a check of its own
5506
+ # passed, which is evidence like any other. The trigger is the first-person
5507
+ # run claim (RAN_IT: "test ettim", "i ran the suite"), never the done or
5508
+ # verified vocabulary: a plan states its acceptance in those words ("Done
5509
+ # when all tests pass", "her adım `pytest -k apps` ile doğrulanır") and
5510
+ # review round 2 of plan 142 caught both refused. Above `_admitted`:
5511
+ # "doğrulanmadı" repairs an unverified claim, and here nothing was checked.
5512
+ if read_only and not any(passing_check(r) for r in rows):
5513
+ ran = _asserted(RAN_IT, t)
5514
+ if ran:
5515
+ return ("plan claim",
5516
+ PLAN_CLAIM % cut(" ".join(ran.group(0).split()), 64))
4892
5517
  fold = _admitted(fold, t)
4893
5518
  # The trigger is the turn's own evidence, not its words. The claim vocabulary
4894
5519
  # below catches a claim-shaped reply; it missed the same unfounded state
@@ -4915,7 +5540,12 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4915
5540
  # `tail` or a read-only query owes no check. Its claim words are read the
4916
5541
  # same way, and a claim is judged against the session's earlier turns below.
4917
5542
  quiet = _bookkeeping_turn(rows, GIT_READS)
4918
- if not worked or quiet:
5543
+ # A turn of reads that loop over files or read a read's output (plan 126:
5544
+ # `for f in $(find src); do cat -n "$f"; done`, refused in 4 of 6 live
5545
+ # advice turns) ends like one with no work - but only on a reply that
5546
+ # claims nothing. A done/tested claim over it keeps the worked turn's fold.
5547
+ read = quiet or _bookkeeping_turn(rows, GIT_READS, loops=True)
5548
+ if not worked or read:
4919
5549
  # With no work, the words are the whole trigger, so a question or a
4920
5550
  # negation is not read as the claim it names (`asserted_claims`).
4921
5551
  done, verified = asserted_claims(t)
@@ -4939,12 +5569,13 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4939
5569
  # exit below - "nothing was done and nothing was claimed" - is exactly the
4940
5570
  # shape that used to leave such a claim unjudged.
4941
5571
  external = _external_claim(t)
4942
- if (not worked or quiet) and not (done or verified) and not external:
4943
- return _residual_block(t, rows, shape, session_id=session_id, cwd=cwd)
5572
+ if (not worked or read) and not (done or verified) and not external:
5573
+ return _residual_block(t, rows, shape, session_id=session_id, cwd=cwd,
5574
+ read_only=read_only)
4944
5575
  if lost:
4945
5576
  return _residual_block(t, rows, shape,
4946
5577
  fold(rows, worked | {BEGAN_KIND}, external),
4947
- session_id, cwd)
5578
+ session_id, cwd, read_only)
4948
5579
  # A check that answered with an outcome nobody saw (exit 0 and nothing
4949
5580
  # returned, a status a pipe owns: not `passing_check`) is no pass, and as a
4950
5581
  # check it is no work row either. A done or tested claim resting on it owes
@@ -4977,7 +5608,7 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4977
5608
  if not external and not unseen and _settled(srows):
4978
5609
  return _residual_block(t, rows, shape, fold(
4979
5610
  _before_turn(srows), True, None, "this session"),
4980
- session_id, cwd)
5611
+ session_id, cwd, read_only)
4981
5612
  if (not worked or quiet) and (done or verified):
4982
5613
  # A claim in a turn that did nothing: "Tamamlandı, tüm testler
4983
5614
  # geçti" after a turn that edited and ended "doğrulanmadı" was
@@ -4998,7 +5629,7 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4998
5629
  if unseen:
4999
5630
  worked = worked | {"verify_ok"}
5000
5631
  return _residual_block(t, rows, shape, fold(judged, worked, external),
5001
- session_id, cwd)
5632
+ session_id, cwd, read_only)
5002
5633
 
5003
5634
 
5004
5635
  def _before_turn(rows):
@@ -5070,12 +5701,12 @@ def _evidence_text(cls, rows, external, where="this turn"):
5070
5701
  % (where, shown or "a UI file"))
5071
5702
  return ("no ui_ok",
5072
5703
  "Design contract: %s changed a component (%s) and no "
5073
- "`tezgah-design check` ran over a measurement, so nothing "
5704
+ "`~/.config/tezgah/bin/tezgah-design check` ran over a measurement, so nothing "
5074
5705
  "says the component is on the repository's floor - a read of "
5075
5706
  "the screen says what it looks like, not whether it is on "
5076
- "that floor. Run `tezgah-design check --contract "
5707
+ "that floor. Run `~/.config/tezgah/bin/tezgah-design check --contract "
5077
5708
  ".tezgah/design-contract.md --measured <measurement.json>` "
5078
- "(`tezgah-design derive` writes the contract when the repo "
5709
+ "(`~/.config/tezgah/bin/tezgah-design derive` writes the contract when the repo "
5079
5710
  "has none; the measurement is the per-component styles and "
5080
5711
  "states the analyze-app loop produces) and report every "
5081
5712
  "violation it prints; if the check cannot run here, write "
@@ -5487,9 +6118,9 @@ RESIDUAL_WORK = (
5487
6118
  # twice more.
5488
6119
  STOPPED_SLICE = (
5489
6120
  "{SLICE_LEAD}Slice %s, spawned by this session, stopped (%s) and "
5490
- "its work is not resumed. Resume it with `tezgah-task slice resume %s` - "
6121
+ "its work is not resumed. Resume it with `~/.config/tezgah/bin/tezgah-task slice resume %s` - "
5491
6122
  "the native path first, else the re-dispatch brief it prints - or, if the "
5492
- "work was finished another way, record that with `tezgah-task slice done "
6123
+ "work was finished another way, record that with `~/.config/tezgah/bin/tezgah-task slice done "
5493
6124
  "%s`.")
5494
6125
 
5495
6126
  # The `ready steps` class (plan 086, S1): the newest todo list (`note_todo`)
@@ -5514,6 +6145,27 @@ OPEN_TODO = (
5514
6145
  "{ACCESS}, {IRREVERSIBLE} or {ENV}, or marked blocked in the todo list "
5515
6146
  "with its reason.")
5516
6147
 
6148
+ # The `handed step` class (plan 142): the reply tells the reader to run a
6149
+ # command this session ran commands beside (`handed_steps`). Filled with up to
6150
+ # three of those lines.
6151
+ HANDED_STEP = (
6152
+ "{HANDED_LEAD}The reply hands the user %d step(s) this session can run "
6153
+ "itself: %s. Run each one now and report what it printed. A step stays the "
6154
+ "user's only when it is outward with no grant from this session, when it "
6155
+ "cannot be undone, or when it needs {ACCESS} or {ENV} - and then its own "
6156
+ "line says which, with the command that failed. In a host plan mode, where "
6157
+ "nothing can run, the plan is the deliverable and the step runs in the turn "
6158
+ "after the approval.")
6159
+
6160
+ # The `plan claim` class (plan 142): a done/tested claim made in a turn the
6161
+ # host ran in a read-only mode (`read_only_turn`), where no command and no
6162
+ # write was possible, so nothing can be behind it.
6163
+ PLAN_CLAIM = (
6164
+ "{PLAN_LEAD}This turn ran in the host's read-only plan mode: no command "
6165
+ "and no write was possible, so \"%s\" has nothing behind it. Say what the "
6166
+ "plan is and which command will prove it, and claim the result in the turn "
6167
+ "that runs it, after the plan is approved.")
6168
+
5517
6169
 
5518
6170
  def _tr_lower(text):
5519
6171
  """`text` lowercased the Turkish way (I -> ı, İ -> i): `str.lower` reads
@@ -5831,6 +6483,10 @@ NEVER_DEFAULT = re.compile(alts("ask", "never_default"), re.I)
5831
6483
  # A reply that names a plan it wrote, or asks to build one: building a plan the
5832
6484
  # turn wrote is class F (plan 105), so asking it is the policy, not a needless ask
5833
6485
  PLAN_BUILD = re.compile(alts("ask", "plan_build"), re.I)
6486
+ # A last line asking the owner to pick a variant or option a research, analysis
6487
+ # or review recommends, to take a way around a tezgah refusal, or to decide
6488
+ # about another session's work (its line, plan or branch): class F (plan 136)
6489
+ OWNER_CHOICE = re.compile(alts("ask", "owner_choice"), re.I)
5834
6490
  SPEND = re.compile(alts("ask", "spend"), re.I)
5835
6491
  AMOUNT = re.compile(r"\$\s?(\d+(?:\.\d+)?)|(\d+(?:\.\d+)?)\s?(?:\$|usd\b|dolar)", re.I)
5836
6492
  DEFAULT_MARK = re.compile(alts("ask", "default_mark"), re.I)
@@ -5851,10 +6507,12 @@ NEEDLESS_ASK = (
5851
6507
  "merge, a run parameter within the standing spend cap - are taken and "
5852
6508
  "stated in one line, and an outward step the user granted this session is "
5853
6509
  "taken, not asked again. An outward step the user has not granted waits "
5854
- "for the answer and never proceeds on a default, and so does building a "
5855
- "plan this turn wrote. Do it now, or, when only "
5856
- "the user can decide it, put it under a \"{APPROVAL_HEADING}\" heading with "
5857
- "a \"{RECOMMENDED}:\" default and proceed on that default.")
6510
+ "for the answer and never proceeds on a default, and so do building a "
6511
+ "plan this turn wrote, choosing a variant or option a research, analysis "
6512
+ "or review recommends, a way around a tezgah refusal and deciding about "
6513
+ "another session's work. Do it now, or, when it is a taste or intent fork "
6514
+ "only the user can decide, put it under a \"{APPROVAL_HEADING}\" heading "
6515
+ "with a \"{RECOMMENDED}:\" default and proceed on that default.")
5858
6516
 
5859
6517
 
5860
6518
  def outward_classes(text):
@@ -5902,7 +6560,9 @@ def needless_ask(text, granted=frozenset):
5902
6560
  """The question the reply ends on when the agent can decide it, else None.
5903
6561
 
5904
6562
  The last line asks (QUESTION_END), its paragraph carries no recommended
5905
- default (DEFAULT_MARK) and it names no class-F step (`never_default`); it
6563
+ default (DEFAULT_MARK) and it names no class-F step (`never_default`, or
6564
+ OWNER_CHOICE: a variant or option to pick, a way around a refusal, another
6565
+ session's work); it
5906
6566
  is then needless when it names an outward step (OUTWARD) the user granted
5907
6567
  this session, every one it names (`granted()`, called only then, so a reply
5908
6568
  that ends on no question reads no ledger), or, naming none, when it asks a
@@ -5913,7 +6573,8 @@ def needless_ask(text, granted=frozenset):
5913
6573
  if not lines or not QUESTION_END.search(lines[-1]):
5914
6574
  return None
5915
6575
  last = lines[-1]
5916
- if (never_default(last) or PLAN_BUILD.search(_tr_lower(t))
6576
+ if (never_default(last) or OWNER_CHOICE.search(_tr_lower(last))
6577
+ or PLAN_BUILD.search(_tr_lower(t))
5917
6578
  or DEFAULT_MARK.search(_tr_lower(re.split(r"\n\s*\n", t)[-1]))):
5918
6579
  return None
5919
6580
  low = _tr_lower(last)
@@ -5925,6 +6586,56 @@ def needless_ask(text, granted=frozenset):
5925
6586
  return None
5926
6587
 
5927
6588
 
6589
+ # The "you run it" shape: a line that tells the reader to run something
6590
+ # (`ask.hand_over`), beside a command in backticks (CODE_SPAN, the module's
6591
+ # own: a fence line carries no backtick span and never qualifies).
6592
+ HAND_OVER = re.compile(alts("ask", "hand_over"), re.I)
6593
+ # The steps the owner runs by design, whatever the session can do: tezgah's own
6594
+ # consent command, its installation and a machine-level `brew` step (the
6595
+ # session-scope rule). An outward or class-F step is read off the line itself
6596
+ # (`outward_classes`, `never_default`).
6597
+ OWNER_STEP = re.compile(r"(?i)\btezgah-(?:consent|setup|update)\b|\bconsent\b|\bbrew\b")
6598
+
6599
+
6600
+ def read_only_turn(mode):
6601
+ """True when the host says this turn ran in a mode that can neither write
6602
+ nor run (READ_ONLY_MODES). A host that sends nothing reads as False."""
6603
+ return str(mode or "").strip().lower().replace("_", "-") in READ_ONLY_MODES
6604
+
6605
+
6606
+ def handed_steps(text, rows, read_only=False):
6607
+ """The lines that hand the reader a step this session could have taken.
6608
+
6609
+ A line qualifies when it names a command in backticks and tells the reader
6610
+ to run it (HAND_OVER: the reader addressed as the one who runs it, never
6611
+ the "you can run X" of an answer about how something is done). A table row
6612
+ (TABLE_ROW) and a quoted line are not
6613
+ the reply speaking: a refusal or a log the reply quotes carries the same
6614
+ words. It is kept - the ask policy's, not the agent's - when it names an
6615
+ outward step (`outward_classes`), a class-F step
6616
+ (`never_default`), the owner's consent or tezgah's own install
6617
+ (OWNER_STEP), or when the line types its blocker. The turn must have run a
6618
+ command of its own (COMMAND_KINDS): a turn that ran none could not have run
6619
+ this one either, which keeps the class inert on a host that sends no mode.
6620
+ `read_only` closes it outright: in a plan mode every step waits for the
6621
+ approval, which is what the contract tells the turn to do."""
6622
+ if read_only or not any(str(r.get("kind")) in COMMAND_KINDS for r in rows):
6623
+ return []
6624
+ out = []
6625
+ for line in str(text or "").splitlines():
6626
+ said = line.strip()
6627
+ if said.startswith(("|", ">")) or TABLE_ROW.match(line):
6628
+ continue
6629
+ if not (CODE_SPAN.search(said) and HAND_OVER.search(said)):
6630
+ continue
6631
+ prose = _prose(said)
6632
+ if (outward_classes(said) or never_default(said) or OWNER_STEP.search(said)
6633
+ or BLOCKER_SAID.search(prose) or BLOCKER_TRIED.search(prose)):
6634
+ continue
6635
+ out.append(said)
6636
+ return out
6637
+
6638
+
5928
6639
  def undefaulted(inp):
5929
6640
  """(questions, with a default) of an ask-tool payload: omp's `ask` marks
5930
6641
  the default with a question's `recommended` index, Claude's
@@ -5980,14 +6691,18 @@ def unblocked_items(text, rows):
5980
6691
 
5981
6692
 
5982
6693
  def _residual_block(text, rows, shape, verdict=(None, None), session_id=None,
5983
- cwd=None):
6694
+ cwd=None, read_only=False):
5984
6695
  """`verdict` when it refuses; else, in a turn that did work (a STEP_KINDS
5985
6696
  row), the `residual work` class when `unblocked_items` names one, or when a
5986
6697
  subagent ended in the turn handing up items no type accounts for (the
5987
6698
  `residual` count on its `subagent_end` row) and this reply types none; else
5988
6699
  the `needless ask` class when `needless_ask` names the question the reply
5989
6700
  ends on (any turn, worked or not: the plan proposal that ends "Başlayayım
5990
- mı?" is its measured case); else the `ready steps` class when `ready_left`
6701
+ mı?" is its measured case) - except in a read-only turn, where the host's
6702
+ plan mode asks for that approval and the agent cannot take the step it
6703
+ would be asked about; else the `handed step` class when `handed_steps`
6704
+ names a step the reply left to the reader; else the `ready steps` class
6705
+ when `ready_left`
5991
6706
  names one; else the `open todo` class when `open_left` names one; else the
5992
6707
  `stopped slice` class when `_stopped_slice` finds one; else `verdict`. Not
5993
6708
  asked for a subagent's end (`shape` false) or a nested session: both
@@ -6019,9 +6734,15 @@ def _residual_block(text, rows, shape, verdict=(None, None), session_id=None,
6019
6734
  move = next(filter(None, (_limit_move(item, failed, spawn_failed)
6020
6735
  for _label, item in left)), None)
6021
6736
  return ("residual work", RESIDUAL_WORK % named + (LIMIT_MOVE % move if move else ""))
6022
- asked = needless_ask(text, lambda: grants_in_force(session_id))
6737
+ asked = None if read_only else needless_ask(
6738
+ text, lambda: grants_in_force(session_id))
6023
6739
  if asked:
6024
6740
  return ("needless ask", NEEDLESS_ASK % cut(asked, 80))
6741
+ passed = handed_steps(text, rows, read_only)
6742
+ if passed:
6743
+ return ("handed step", HANDED_STEP % (
6744
+ len(passed), "; ".join(cut(" ".join(line.split()), 64)
6745
+ for line in passed[:3])))
6025
6746
  todo = _turn_todo(rows, session_id)
6026
6747
  ready = _ready_in(text, todo, session_id, cwd)
6027
6748
  if ready: