@r1z4x/tezgah 1.6.0 → 1.8.2

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 (71) hide show
  1. package/CHANGELOG.md +140 -1
  2. package/MANIFEST +7 -0
  3. package/README.md +6 -3
  4. package/bin/tezgah-decisions +6 -14
  5. package/bin/tezgah-design +21 -5
  6. package/bin/tezgah-docs +18 -16
  7. package/bin/tezgah-doctor +152 -0
  8. package/bin/tezgah-lessons +4 -1
  9. package/bin/tezgah-research +249 -17
  10. package/bin/tezgah-route +70 -8
  11. package/bin/tezgah-setup +26 -13
  12. package/bin/tezgah-task +877 -60
  13. package/bin/tezgah-taste +56 -15
  14. package/docs/architecture.md +28 -11
  15. package/docs/clarity-baseline.json +2 -2
  16. package/docs/contract.md +108 -24
  17. package/docs/environment.md +3 -1
  18. package/docs/evidence.md +209 -28
  19. package/docs/feature-audit.md +1 -1
  20. package/docs/gate.md +78 -23
  21. package/docs/glossary.md +4 -4
  22. package/docs/hosts.md +8 -8
  23. package/docs/index.json +11 -5
  24. package/docs/judge.md +9 -3
  25. package/docs/models.md +75 -14
  26. package/docs/operations.md +133 -3
  27. package/docs/research.md +349 -15
  28. package/docs/skills.md +1 -1
  29. package/docs/status-line.md +5 -5
  30. package/docs/testing.md +20 -13
  31. package/docs/vision.md +12 -4
  32. package/hooks/hooks.json +2 -2
  33. package/hooks/lang/en.json +323 -0
  34. package/hooks/lang/tr.json +344 -0
  35. package/hooks/projects-auto-init.py +29 -1
  36. package/hooks/projects-posttooluse.py +24 -5
  37. package/hooks/projects-pretooluse.py +9 -4
  38. package/hooks/projects-stop.py +28 -16
  39. package/hooks/tezgah_agents.py +53 -17
  40. package/hooks/tezgah_clarity.py +7 -3
  41. package/hooks/tezgah_context.py +489 -438
  42. package/hooks/tezgah_gate.py +996 -80
  43. package/hooks/tezgah_integrity.py +1547 -197
  44. package/hooks/tezgah_models.py +427 -45
  45. package/hooks/tezgah_paths.py +147 -16
  46. package/hooks/tezgah_policy.py +261 -129
  47. package/hooks/tezgah_rank.py +29 -27
  48. package/hooks/tezgah_replay.py +200 -30
  49. package/hooks/tezgah_research.py +1303 -14
  50. package/hooks/tezgah_stopspec.py +7 -0
  51. package/hooks/tezgah_store.py +74 -27
  52. package/hooks/tezgah_task.py +1201 -40
  53. package/hooks/tezgah_taste.py +8 -4
  54. package/hosts/codex/hook.py +32 -13
  55. package/hosts/cursor/hook.py +32 -14
  56. package/hosts/dsh/hooks.json +2 -2
  57. package/hosts/omp/hook.py +44 -23
  58. package/hosts/omp/tezgah-hook.ts.in +93 -7
  59. package/hosts/opencode/plugins/tezgah.js +273 -6
  60. package/output-styles/tezgah.md +76 -42
  61. package/package.json +1 -1
  62. package/skills/i-have-adhd/EVALS.md +22 -2
  63. package/skills/i-have-adhd/SKILL.md +12 -5
  64. package/skills/plan-add/SKILL.md +27 -11
  65. package/skills/plan-add/render_table.py +8 -3
  66. package/skills/plan-sync/SKILL.md +5 -3
  67. package/skills/ponytail/EVALS.md +21 -2
  68. package/skills/ponytail/SKILL.md +4 -4
  69. package/skills/research/SKILL.md +89 -1
  70. package/skills/tezgah-contract/EVALS.md +52 -7
  71. package/skills/tezgah-contract/SKILL.md +154 -59
@@ -19,20 +19,22 @@ import tezgah_orca
19
19
  import tezgah_research
20
20
  import tezgah_store
21
21
  from tezgah_guard import import_crash_key
22
- from tezgah_integrity import (STEP_KINDS, _heredocs, _path as _ledger_path,
22
+ from tezgah_integrity import (GRANT_KIND, STEP_KINDS, _heredocs, _path as _ledger_path,
23
23
  _shell_segments, bind_session,
24
24
  changed_files, cut, last_check, note,
25
- note_compaction, note_turn, redact, scratch_evidence,
26
- UNTRUSTED_CHANNEL)
25
+ note_compaction, note_turn, outward_classes, redact,
26
+ scratch_evidence, UNTRUSTED_CHANNEL)
27
27
  from tezgah_lessons import (lesson_key, lines as lesson_lines,
28
28
  tainted as tainted_lessons)
29
29
  from tezgah_policy import (CONDITIONAL_KEYS, CORE, FANOUT_ACTION, POINTERS,
30
- PROMPT_REMINDER, REPLY_LANG_TEXT, open_lines_note,
30
+ PROMPT_REMINDER, REPLY_ANY, open_lines_note,
31
31
  pony_level_line)
32
- from tezgah_paths import (CACHE, REPO_MARKS, SWITCHES, ai_research_dir, armed,
32
+ from tezgah_paths import (CACHE, CONTEXT_NUDGE_TOKENS, REPO_MARKS, SWITCHES,
33
+ ai_research_dir, alts, armed,
33
34
  cache_dir, codegraph_bin, consult_options,
34
- ensure_workspace, fallback_cache, have_judge_key, off,
35
- orx_bin, pony_level, reply_lang, root_for, roots, tool,
35
+ ensure_workspace, fallback_cache, have_judge_key, language,
36
+ off, orx_bin, packs, pony_level, project_top, root_for,
37
+ roots, say, tool,
36
38
  workspace, workspace_from_repo, worktrees, writable_dir)
37
39
 
38
40
  try: # The task record is the active plan's frontmatter (see tezgah_task), read
@@ -55,325 +57,95 @@ except ImportError: # pragma: no cover - only where the module has not landed
55
57
 
56
58
  # A prompt that matches one of these arms the matching conditional rule for that
57
59
  # turn only. Kept as (key, compiled regex) so the arming is one pass and the
58
- # patterns are reviewable. Word-ish boundaries keep "deploy" from firing inside
59
- # an identifier; Turkish hints are included because the user writes Turkish.
60
+ # patterns are reviewable. The words are data: each language pack
61
+ # (hooks/lang/<code>.json, `hints.<key>`) lists its own half, and `_hints`
62
+ # joins every pack's into one pattern per key, so a prompt in any shipped
63
+ # language arms the same rules. Word-ish boundaries keep "deploy" from firing
64
+ # inside an identifier.
60
65
  #
61
- # Turkish is agglutinative and the hints are verb/noun stems, so an English-style
62
- # trailing \b would kill the most natural phrasing (düzgün çalışsın, hipotezi
63
- # test et): a Turkish alternation therefore carries \w* where a suffix can land,
64
- # which \b then closes. \w is Unicode-aware, so it eats Türkçe letters. A whole
65
- # word keeps its plain boundary, so "deney" still does not fire on "deneyim".
66
+ # Turkish is agglutinative and its hints are verb/noun stems, so an
67
+ # English-style trailing \b would kill the most natural phrasing (düzgün
68
+ # çalışsın, hipotezi test et): such a fragment carries \w* where a suffix can
69
+ # land, which \b then closes. \w is Unicode-aware. A whole word keeps its plain
70
+ # boundary, so "deney" still does not fire on "deneyim".
66
71
  #
67
- # The craft words in the spec class that are ALSO ordinary English nouns are
68
- # qualified by one of these rather than matched bare: the bare word armed the
69
- # paragraph on asks that had no UI in them at all. One closed list and one
70
- # helper, so qualifying a new sibling is a one-line change and no word can be
71
- # left bare by omission - `_ui_ask` is applied at the word's own place in the
72
- # pattern below, and both halves are pinned in tests/test_hint_coverage.py.
73
- # Every entry is word-bounded, so `app` cannot match inside `happen`.
74
- UI_OBJECTS = (r"components?|ui|ux|css|layout|styles?|tokens?|themes?|buttons?|"
75
- r"cards?|icons?|labels?|elements?|widgets?|containers?|headers?|"
76
- r"footers?|sidebars?|toolbars?|navbars?|nav|menu|modals?|dialogs?|"
77
- r"dropdowns?|popovers?|tooltips?|badges?|avatars?|spinners?|"
78
- r"skeletons?|inputs?|fields?|forms?|headings?|images?|logos?|"
79
- r"grids?|panels?|columns?|screens?|pages?|views?|dashboards?|"
80
- r"viewport|tables?|app")
81
- # What a UI ask says about the thing: "the badges look wrong", "the theme
82
- # renders late". A quality word alone answers for the object it follows.
83
- UI_QUALITY = r"look\w*|feels?\w*|seems?\w*|renders?\w*"
84
-
85
-
86
- def _ui_ask(word):
87
- """`word` as a UI ask: a UI object beside it, or a quality word after it.
88
-
89
- The gap is bounded and stops at a sentence's end, so the qualification is
90
- about the phrase the word sits in and not about the paragraph it is in."""
91
- obj = r"\b(?:%s)\b" % UI_OBJECTS
72
+ # The craft words in the spec class that are ALSO ordinary nouns are qualified
73
+ # by a UI object rather than matched bare (a pack fragment `{"ui_ask": word}`):
74
+ # the bare word armed the paragraph on asks that had no UI in them at all.
75
+ # `_ui_ask` expands one in the pack's own word order - a `verb_first` pack (en)
76
+ # takes a UI object beside the word or a quality word after it, an
77
+ # `object_first` pack (tr: "adım akışını sadeleştir" is a UI ask, "bu
78
+ # fonksiyonu sadeleştir" a refactor) only an object before it - and both halves
79
+ # are pinned in tests/test_hint_coverage.py. Every object is word-bounded, so
80
+ # `app` cannot match inside `happen`.
81
+
82
+
83
+ def _ui_ask(word, pack):
84
+ """`word` as a UI ask in `pack`'s word order. The gap is bounded and stops
85
+ at a sentence's end, so the qualification is about the phrase the word sits
86
+ in and not about the paragraph it is in."""
87
+ objects = "|".join(pack["ui_objects"]) or "(?!)"
88
+ if pack["word_order"] == "object_first":
89
+ # the object takes its case suffix through \w*
90
+ return r"(?:\b(?:%s)\w*[^.!?\n]{0,40}\b(?:%s))" % (objects, word)
91
+ obj = r"\b(?:%s)\b" % objects
92
92
  return (r"(?:%s[^.!?\n]{0,40}\b(?:%s)\b"
93
93
  r"|\b(?:%s)\b(?=[^.!?\n]{0,40}(?:%s|\b(?:%s))))"
94
- % (obj, word, word, obj, UI_QUALITY))
95
-
96
-
97
- # The Turkish surfaces a simplify ask names before its verb (Turkish puts the
98
- # object first): "adım akışını sadeleştir" is a UI ask, "bu fonksiyonu
99
- # sadeleştir" is a refactor. Each takes its case suffix through \w*.
100
- TR_UI_OBJECTS = (r"akış|adım|ekran|sayfa|arayüz|form|menü|buton|düğme|panel|"
101
- r"tasarım|görünüm|kart|bileşen|sekme|pencere")
102
-
103
-
104
- def _tr_ui_ask(word):
105
- """`word` after a Turkish UI surface in the same phrase - `_ui_ask`'s
106
- object-first half, for the language whose object comes first."""
107
- return (r"(?:\b(?:%s)\w*[^.!?\n]{0,40}\b(?:%s))" % (TR_UI_OBJECTS, word))
108
-
109
-
110
- # The fan-out hint's parts (PROMPT_HINTS["fanout"]). A list item's marker:
111
- # `1.`, `1)`, `1 -`, `1:`, `a)`, or a `-`/`*`/`+`/`•` bullet, then its text.
112
- _FAN_ITEM = r"(?:\d+[ \t]*[.):\u2013-]|[a-z]\)|[-*+\u2022])[ \t]+\S"
113
- # A file a prompt names: a path with a directory, or a name with an extension.
114
- # Anchored at a token start: unanchored, a long run of word characters made
115
- # every position a start and the search quadratic (100 KB took minutes).
116
- _FAN_FILE = (r"(?<![\w./-])[`'\"]?(?:(?:[\w.-]+/)+[\w.-]*\w|"
117
- r"\w[\w-]*\.[a-z]{1,4}\b)[`'\"]?")
118
- # The imperative that opens an English clause and closes a Turkish one.
119
- _FAN_VERB_EN = (r"(?:fix|add|update|write|remove|delete|rename|move|refactor|"
120
- r"document|bump|create|implement|migrate|replace)")
121
- _FAN_VERB_TR = (r"(?:düzelt|güncelle|ekle|yaz|sil|kaldır|taşı|bitir|tamamla|"
122
- r"incele|oluştur|değiştir)")
123
-
124
-
125
- PROMPT_HINTS = (
126
- ("spec", r"\b(normal (user )?behaviou?r|clean ui|nicer|more intuitive|"
127
- r"un?professional|polish(ed)?|improve the (ui|ux)|make it (better|"
128
- r"usable|look)|look(s)? better|düzgün çalış\w*|düzgün görün\w*|"
129
- r"güzel görün\w*|daha iyi (ol|görün)\w*|kullanıcı dostu|"
130
- r"modern görün\w*|şık (ol|görün)\w*|profesyonel görün\w*|"
131
- r"temiz (bir )?(arayüz|görün)\w*|anlaşılır\w*|"
132
- # simplify is qualified by the surface it is about: "bu fonksiyonu
133
- # sadeleştir" is a refactor, "adım akışını sadeleştir" a UI ask
134
- r"kullanılabilirlik\w*|"
135
- + _tr_ui_ask(r"basitleştir\w*|sadeleştir\w*") + r"|"
136
- r"yeniden tasarla\w*|baştan tasarla\w*|"
137
- # UI work said as work. The class fired on a quality adjective
138
- # alone, so "refactor the components" or "the hover state is wrong"
139
- # armed nothing and a UI task was built with no standard named for
140
- # what a person sees. These are the craft words and the rendered
141
- # formats, not the surface nouns: "ekran" and "arayüz" stay with the
142
- # product class, so a surface ask still pays one paragraph.
143
- #
144
- # Each craft word that is also an ordinary English noun is qualified
145
- # by a UI object through `_ui_ask` (above), because the bare word
146
- # armed the paragraph on asks that are not UI at all - "refactor the
147
- # parser module", "align the two arrays", "what colors does
148
- # matplotlib use", "add a font to the PDF", "the card model in the
149
- # game", "focus the terminal window" - and the same held for the six
150
- # siblings below: "our margins are down this quarter", "the theme of
151
- # the meeting", "the badges in the README", "scaffold the skeleton of
152
- # the parser", "cluttered imports in the module", "the data center".
153
- # The UI asks still arm it through `component\w*`, `ui`, `layout`,
154
- # `spacing`, `hover`, `focus durum\w*`, `hizala\w*`, `renk\w*` and
155
- # the rest (tests/test_hint_coverage.py holds both halves).
156
- r"refactor\w* (the |this |our )?(\w+ ){0,2}(components?|ui|ux|css|"
157
- r"layout|screens?|pages?|styles?|theme|forms?|fields?|inputs?|"
158
- r"views?|widgets?|buttons?|modals?|dialogs?)|"
159
- r"ui|ux|css|tailwind|tasarım\w*|layout|responsive|"
160
- r"hover|focus[- ](?:state|ring|outline|visible|indicator|style|"
161
- r"durum\w*)|spacing|tipografi\w*|typography|component\w*|"
162
- r"bileşen\w*|dark mode|dark theme|light theme|"
163
- + _ui_ask(r"theme\w*") + r"|tema\w*|animasyon\w*|animations?|"
164
- r"erişilebilir\w*|accessib\w*|wireframe|mockup|prototip\w*|"
165
- # The craft, and the surfaces a person names without the word
166
- # component. Kept off the product class's surface nouns on purpose
167
- # (tablo, filtre, ekran stay there) so an ordinary surface ask still
168
- # pays one paragraph, and off bare `table`/`filter` so that pin holds.
169
- # `align`/`alignment`/`misalign` are one family with the property
170
- # spellings: `align-?items` allowed only a hyphen between the two
171
- # words, so "align items in the header" armed nothing and neither
172
- # adjective form was a hint at all. The nouns read bare the way
173
- # `spacing` does - qualifying them by a UI object would drop "the
174
- # alignment is off", the ask that named this defect - and the
175
- # non-UI phrasings stay dead in the corpus ("align the two arrays",
176
- # "align the teams on the roadmap").
177
- r"align[- ]?(?:items|self|content)|text-align|alignment\w*|"
178
- r"misalign\w*|hizala\w*|hiza\w*|"
179
- r"padding|" + _ui_ask(r"cent(er|re)\w*") + r"|"
180
- + _ui_ask(r"margins?") + r"|"
181
- r"gutter\w*|boşluk\w*|satır aral\w*|line-?height|letter-?spacing|"
182
- r"drop-?shadow|box-?shadow|border-?radius|corner radius|gölge\w*|"
183
- r"kenarlık\w*|fonts?[- ](?:size|family|weight|face|stack|scale)\w*|"
184
- r"yazı tipi\w*|punto\w*|type ?scale|"
185
- r"colou?r[- ](?:role|scheme|palette|token|contrast)|palette|"
186
- r"renk\w*|palet\w*|"
187
- r"cards?[- ](?:component\w*|layout|grid|list|view|title|body|"
188
- r"footer|header)|sidebars?|toolbars?|modals?|dialogs?|dropdowns?|"
189
- r"popovers?|accordions?|tooltips?|toasts?|avatars?|"
190
- + _ui_ask(r"badges?") + r"|"
191
- # pagination is qualified like the craft nouns above: "add
192
- # pagination to the API" is an endpoint, "the data table pagination
193
- # is wrong" a rendered surface
194
- r"breadcrumbs?|" + _ui_ask(r"pagination") + r"|tab ?bar|steppers?|"
195
- r"spinners?|"
196
- + _ui_ask(r"skeletons?") + r"|"
197
- r"disabled\w*|devre dışı\w*|pressed|active state|selected state|"
198
- r"empty state|boş durum\w*|breakpoints?\w*|kırılma nokta\w*|"
199
- r"viewport|media quer\w*|mobil (uyumlu|görünüm)|karanlık mod\w*|"
200
- r"aydınlık mod\w*|renk şema\w*|design (system|token|guide|library|"
201
- r"language)\w*|style guides?|stil rehber\w*|tasarım sistemi|"
202
- r"tasarım token\w*|wcag\w*|a11y|aria-?\w*|screen readers?|"
203
- r"ekran okuyucu\w*|tab order|keyboard naviga\w*|kontrast\w*|"
204
- r"contrast (ratio|level|check|issue)|screenshots?|"
205
- r"ekran görüntü\w*|visual (review|regression|diff|check)|"
206
- r"görsel (incele|kontrol|karşılaştır)\w*|redesign\w*|revamp\w*|"
207
- r"restyle\w*|makeover|" + _ui_ask(r"cluttered")
208
- + r"|outdated|cohesive)\b"),
209
- ("consult", r"\b(architect(ure|ural)|root cause|migrat(e|ion)|deploy|"
210
- r"security|trade-?off|which approach|design decision|"
211
- r"irreversible|rollback|schema change|mimari\w*|kök neden\w*|"
212
- r"geri dönüşü olmayan|"
213
- # The durable-structure asks the rule's own trigger list names,
214
- # which reached no rule: a rewrite of a unit, a contract others
215
- # inherit, a live-data move, a budget or a hard-to-undo call.
216
- # `refactor` is qualified by its object rather than left bare, so
217
- # a UI refactor still pays the spec paragraph alone; `güvenlik`,
218
- # `şema` and `göç` are the Turkish halves of security/schema/
219
- # migration, which the rule text already named in English only.
220
- r"re-?architect\w*|restructur\w*|rewrite\w*|"
221
- r"(refactor\w*|rewrite\w*) (the |this |our )?(\w+ ){0,2}"
222
- r"(module|subsystem|service|system|layer|engine|architecture|"
223
- r"data ?model|code ?base|ingest\w*)|"
224
- r"breaking change\w*|backwards?[- ]incompatib\w*|"
225
- r"kırıcı değişiklik\w*|geriye dönük uyum\w*|"
226
- r"api version\w*|version(ing)? the api|api sürüm\w*|"
227
- r"sürümleme\w*|versiyonlama\w*|backfill\w*|şema\w*|göç\w*|"
228
- r"migrasyon\w*|veri (taşı|göç)\w*|revert\w*|rollback|"
229
- r"geri al\w*|geri (alınamaz|dönüşü (yok|olmaz|olmayan))|"
230
- r"performance budget|perf budget|latency budget|hot ?path|"
231
- r"performans bütçe\w*|gecikme bütçe\w*|threat model\w*|"
232
- r"security review\w*|güvenlik\w*|zafiyet\w*|cve\b|"
233
- r"threat model\w*|bağımlılık(ları)? güncelle\w*|"
234
- r"paket güncelle\w*|sürüm yükselt\w*|tedarik zincir\w*|"
235
- r"concurren\w*|race condition\w*|deadlock\w*|eş ?zamanlı\w*|"
236
- r"yarış (durumu|koşulu)\w*|capacit\w* (plan|budget|test|limit)\w*|"
237
- r"scal(e|ing) (plan|strategy|limit|test)\w*|load test\w*|"
238
- r"kapasite (plan|hesab|test|sınır)\w*|ölçeklen\w*|yük test\w*|"
239
- r"adrs?\b|architecture decision record\w*|design doc\w*|"
240
- r"mimari karar\w*|tasarım karar\w*|politika değiş\w*|"
241
- r"sözleşme (değiş|yenile)\w*|yayına al\w*|canlıya al\w*|"
242
- r"sürüm çıkar\w*|rollout|ödün (ver|vermey)\w*)\b"),
243
- ("research", r"\b(research|literature|hypothes(is|es)|experiment(al)?|"
244
- r"ablation|hyperparameter|benchmark|survey|paper|dataset|"
245
- r"araştır\w*|literatür\w*|hipotez\w*|deney|"
246
- # The study asks the rule's own opening names - a comparison of
247
- # variants under a metric, a study with people, a claim that
248
- # needs a number - which reached no rule. `compar` is qualified
249
- # nowhere on purpose: a file diff asks the same question of the
250
- # evidence, so the turn pays the paragraph and says so.
251
- r"compar\w*|karşılaştır\w*|karşılaştırmalı analiz|kıyaslama\w*|"
252
- r"varyant\w*|(a/?b|ab) tests?|a/b testi|"
253
- r"(user|kullanıcı|müşteri) (interview\w*|görüşme\w*|mülakat\w*)|"
254
- r"anket\w*|root[ -]?cause (analys|investigat)\w*|"
255
- r"kök neden (analiz|araştır)\w*|reference review|"
256
- r"lit(erature)?[ -]review|kaynak tarama\w*|referans tarama\w*|"
257
- r"evaluat(e|ing) (whether|the (librar|technique|approach|tool|"
258
- r"option|alternative))|(yöntem|yaklaşım|kütüphane|varyant|"
259
- r"seçenek)\w* karşılaştır\w*|"
260
- # a timing ask is not a study: "measure how long the hook
261
- # takes" is a stopwatch, "measure the effect of X" a study
262
- r"measure(?! how (?:long|fast|much time)| the (?:time|duration|"
263
- r"latency|speed|runtime))\w*|ölçüm\w*|deneysel\w*|"
264
- r"deneyler\w*|makale\w*|veri (seti|kümesi)|post[ -]?mortem|"
265
- r"error budget|incident (review|report)|olay sonrası (analiz|"
266
- r"değerlendirme)\w*|hata bütçe\w*|is (this|that|it) (actually )?"
267
- r"(true|right)|does (this|that) (claim )?hold|gerçekten (doğru|"
268
- r"öyle) mu|kanıt\w*|test kapsam\w*|kapsam oran\w*|"
269
- r"kapsamı (artır|yükselt)\w*|doküman(tasyon)?\w* "
270
- r"(yetersiz|eksik|kötü|zayıf))\b"),
271
- # A product question reached no rule at all before this: the four above are
272
- # about code, a UI adjective or a study, so "ürünümü nasıl iyileştiririz"
273
- # armed nothing and the answer came from priors. `product` excludes
274
- # production/productivity/productive explicitly - those are code words that
275
- # merely share the prefix, and matching them would arm product analysis on a
276
- # deploy question.
277
- # The generic stems are qualified, never dropped (decision 006): each keeps
278
- # its product sense and loses the code sense a lookahead names - a feature
279
- # flag, a segment fault, a tier list, writing to the screen, a SQL table, a
280
- # page number - so the frozen corpus keeps its rows.
281
- ("product", r"\b(ürün\w*|product(?!ion|ivity|ive)\w*|"
282
- r"feature(?!s?[ -](?:flags?|toggles?|gates?|branch\w*))\w*|roadmap|"
283
- r"yol harita\w*|backlog|prd|north star|kuzey yıldız\w*|jtbd|"
284
- r"retention|churn|onboarding|aktivasyon\w*|cohort|funnel|"
285
- r"dönüşüm\w*|conversion rate|pricing|fiyatlandır\w*|"
286
- r"prioriti[sz]\w*|önceliklendir\w*|user research|"
287
- r"user interview\w*|kullanıcı araştırma\w*|ürün keşf\w*|"
288
- r"müşteri geri bildirim\w*|ab test|a/b test|"
289
- # The product vocabulary the rule's five axes name and the table
290
- # never carried: the HEART signals as people say them, the
291
- # packaging and positioning words, the segmentation input, and
292
- # the plural forms the bare singulars' trailing \b rejected.
293
- r"activations?\b|churn(ed|ing|s)?|funnels?|cohorts?|drop-?off|"
294
- r"roadmaps?|priorit(y|ies)|adoption|benimsen\w*|"
295
- r"conversion (rate|funnel|drop\w*)|packaging|monetiz\w*|"
296
- r"price\w*|tier(?!s?[ -]lists?)\w*|positioning|value proposition|"
297
- r"konumlandır\w*|\bicp\b|ideal customer profile|persona\w*|"
298
- r"segment(?!(?:ation)?[ -]faults?)\w*|segmentasyon\w*|hedef kitle\w*|"
299
- r"(customer|user|product|problem|kullanıcı|müşteri) discovery|"
300
- r"keşif (görüşme|çalışma)\w*|opportunity (tree|solution|space|"
301
- r"score)|fırsat\w*|\bnps\b|net promoter|\bcsat\b|satisfaction|"
302
- r"memnuniyet\w*|anket\w*|(customer|user|kullanıcı|müşteri)"
303
- r"[ -]?(feedback|geri bildirim)\w*|support ticket\w*|"
304
- r"şikayet\w*|competitor\w*|competitive (analysis|landscape|"
305
- r"teardown|benchmark)\w*|rakip\w*|rekabet\w*|pazar pay\w*|"
306
- r"terk oran\w*|elde tutma\w*|abonelik\w*|gelir model\w*|"
307
- r"özellik\w*|sayfa(?! numara)\w*|"
308
- # A single feature said by its surface: an admin screen, a table,
309
- # a filter, a form, a step flow. These armed nothing before, so a
310
- # feature-level audit got a screen-level answer. The lookaheads
311
- # keep the code senses out: "ekran kartı" is a GPU, "adım sayısı"
312
- # is a count, "format" is not a form.
313
- r"ekran(?! kart)(?!a\b[^.!?\n]{0,40}\b(?:yaz|bas)\w*)\w*|arayüz\w*|"
314
- r"arama kutu\w*|filtre\w*|"
315
- r"tablo(?![^.!?\n]{0,60}\b(?:sütun|kolon|sql|index|indeks)\w*)\w*|"
316
- r"wizard|adım(?! sayı)\w*|crud|kullanıcı liste\w*|"
317
- r"form(u|un|da|daki|lar|ları|unu)\w*|"
318
- r"form (validation|field|error|label)|data table|step flow|"
319
- r"search (dropdown|box)|form validation|user management|"
320
- r"(admin|users?) (panel|screen|page|list)|"
321
- + _ui_ask(r"(?:admin|users?) table") + r")\b"),
322
- ("graph", r"\b(who calls|callers?|call sites?|who uses|what breaks|"
323
- r"blast radius|where is|where's|definition of|who invokes|"
324
- r"kim çağır\w*|çağrı yerleri|nerede tanımlı|nasıl bağlan\w*|"
325
- r"etkilenir\w*|hangi dosyalar etkilen\w*|"
326
- # The structural questions the graph answers and the table never
327
- # asked: impact in the active voice, direction and layering, the
328
- # import edge, reachability, cycles, entry points, and a rename's
329
- # blast radius. The Turkish who-uses form is anchored to a code noun,
330
- # because bare "kim kullanıyor" is user research, not a caller.
331
- r"impact (analysis|assessment|of (changing|renaming|removing|"
332
- r"deleting|editing))|change impact|ripple effect|"
333
- r"(who|what|anything)( else)? (depends on|uses)|dependents? of|"
334
- r"dependenc(y|ies) (graph|direction|inversion|cycle)|"
335
- r"layer (violation|boundar\w*|direction)|layering|who imports|"
336
- r"which (files|modules) import|call (graph|chain|path|tree|"
337
- r"hierarchy)|transitive callers?|callers? of|usages? of|"
338
- r"find usages|references? to|is \w+ (still )?(used|referenced|"
339
- r"called)|never called|dead code|unused (code|function|method|"
340
- r"module|export|class|variable|import|param\w*)|unreachable code|"
341
- r"orphan(ed)? (module|file|code)|circular (import|dependenc\w*)|"
342
- r"(import|dependenc\w*) cycle|cycle (between|detection)|"
343
- r"(code|app|program|service|main|server|cli|boot) entry ?points?|"
344
- r"boot (path|sequence|order)|startup (path|sequence|order)|"
345
- r"how is (this|it|\w+) wired|wired up|rename\w*|"
346
- r"etki (analiz|alan)\w*|neyi etkile\w*|neleri etkile\w*|"
347
- r"etkilen\w*|bağımlı\w*|kullanılmayan\w*|kullanılmıyor|ölü kod|"
348
- r"döngüsel (bağımlılık|import)\w*|giriş nokta\w*|"
349
- r"uygulama (nerede|nereden) başl\w*|kim import ed\w*|"
350
- r"(fonksiyon|metod|sınıf|modül|api)\w* (kim|nerede) kullan\w*|"
351
- r"nerede (kullanıl|çağrıl)\w*|tanım\w* nerede|kullanım yerleri|"
352
- r"çağrı (zinciri|grafiği)|bağımlılık (grafiği|ağacı)|"
353
- r"yeniden adlandır\w*)\b"),
354
- # Two or more independent items: a numbered or bulleted list of at least
355
- # two entries on their own lines, or the explicit delegation ask. The owner
356
- # had to say "do everything with subagents and in parallel" on every task
357
- # because nothing armed the fan-out rule from the shape of the request.
358
- # `parallelize` alone is code work, so only the delegation phrasings count.
359
- # The owner's own shapes armed nothing until 2026-10-09: `1 - `, `1- `,
360
- # `1:`, `a)`, `+` items, an inline `1. ... 2. ...`, "subagentlerle", three
361
- # files named as one list, "First, ... Second, ...", and three imperative
362
- # clauses in one sentence (clause-initial in English, clause-final in
363
- # Turkish). A timestamp, a range and a traceback stay out (the corpus).
364
- ("fanout", r"(?ms:^[ \t]*" + _FAN_ITEM + r".*?^[ \t]*" + _FAN_ITEM + r")|"
365
- r"(?<![\w.])1[ \t]*[.)-][ \t]+\S[^\n]*?(?<![\w.])2[ \t]*[.)-][ \t]+\S|"
366
- + _FAN_FILE + r"(?:[ \t]*(?:,[ \t]*(?:(?:and|ve)[ \t]+)?|"
367
- r"(?:and|ve|&)[ \t]+)" + _FAN_FILE + r"){2,}|"
368
- r"(?s:\bfirst(?:ly)?[,:].*?\bsecond(?:ly)?[,:])|"
369
- r"(?s:\bbirinci(?:si)?[,:].*?\bikinci(?:si)?[,:])|"
370
- r"(?m:(?:(?:^|[,;]|\band\b)[ \t]*(?:and[ \t]+)?" + _FAN_VERB_EN
371
- + r"\b[^\n]*?){3})|"
372
- r"(?m:(?:\b" + _FAN_VERB_TR + r"\w{0,4}[ \t]*(?:[,;.]|\bve\b|$)"
373
- r"[^\n]*?){3})|"
374
- r"\b(?:in parallel|paralel\w*|sub-?agent\w*|alt ?ajan\w*|"
375
- r"worktrees?|fan[- ]?out|orchestrat\w*|orkestra\w*)\b"),
376
- )
94
+ % (obj, word, word, obj, "|".join(pack["ui_quality"]) or "(?!)"))
95
+
96
+
97
+ def _hints(key):
98
+ """Every pack's `hints.<key>` fragments as one `\\b(...)\\b` pattern."""
99
+ return r"\b(%s)\b" % "|".join(
100
+ frag if isinstance(frag, str) else _ui_ask(frag["ui_ask"], pack)
101
+ for pack in packs().values() for frag in pack["hints"][key])
102
+
103
+
104
+ def _fanout():
105
+ """The fan-out hint: two or more independent items - a numbered or bulleted
106
+ list of at least two entries on their own lines, or the explicit delegation
107
+ ask. The owner had to say "do everything with subagents and in parallel" on
108
+ every task because nothing armed the fan-out rule from the shape of the
109
+ request. `parallelize` alone is code work, so only the delegation phrasings
110
+ count. The owner's own shapes armed nothing until 2026-10-09: `1 - `, `1- `,
111
+ `1:`, `a)`, `+` items, an inline `1. ... 2. ...`, "subagentlerle", three
112
+ files named as one list, "First, ... Second, ...", and three imperative
113
+ clauses in one sentence (clause-initial in a `verb_first` pack, clause-final
114
+ in an `object_first` one). A timestamp, a range and a traceback stay out
115
+ (the corpus)."""
116
+ # A list item's marker: `1.`, `1)`, `1 -`, `1:`, `a)`, or a `-`/`*`/`+`/`•`
117
+ # bullet, then its text.
118
+ item = r"(?:\d+[ \t]*[.):\u2013-]|[a-z]\)|[-*+\u2022])[ \t]+\S"
119
+ # A file a prompt names: a path with a directory, or a name with an
120
+ # extension. Anchored at a token start: unanchored, a long run of word
121
+ # characters made every position a start and the search quadratic (100 KB
122
+ # took minutes).
123
+ file = (r"(?<![\w./-])[`'\"]?(?:(?:[\w.-]+/)+[\w.-]*\w|"
124
+ r"\w[\w-]*\.[a-z]{1,4}\b)[`'\"]?")
125
+ join = "|".join(w for p in packs().values() for w in p["fan_join"])
126
+ parts = [r"(?ms:^[ \t]*" + item + r".*?^[ \t]*" + item + r")",
127
+ r"(?<![\w.])1[ \t]*[.)-][ \t]+\S[^\n]*?(?<![\w.])2[ \t]*[.)-][ \t]+\S",
128
+ file + r"(?:[ \t]*(?:,[ \t]*(?:(?:%s)[ \t]+)?|(?:%s|&)[ \t]+)" % (join, join)
129
+ + file + r"){2,}"]
130
+ for pack in packs().values():
131
+ first, second = pack["fan_ordinals"]
132
+ parts.append(r"(?s:\b%s[,:].*?\b%s[,:])" % (first, second))
133
+ for pack in packs().values():
134
+ verbs, joins = "|".join(pack["fan_verbs"]), "|".join(pack["fan_join"])
135
+ if pack["word_order"] == "object_first":
136
+ parts.append(r"(?m:(?:\b(?:%s)\w{0,4}[ \t]*(?:[,;.]|\b(?:%s)\b|$)"
137
+ r"[^\n]*?){3})" % (verbs, joins))
138
+ else:
139
+ parts.append(r"(?m:(?:(?:^|[,;]|\b(?:%s)\b)[ \t]*(?:(?:%s)[ \t]+)?(?:%s)"
140
+ r"\b[^\n]*?){3})" % (joins, joins, verbs))
141
+ parts.append(r"\b(?:%s)\b" % "|".join(
142
+ d for p in packs().values() for d in p["fan_directives"]))
143
+ return "|".join(parts)
144
+
145
+
146
+ PROMPT_HINTS = tuple((key, _hints(key)) for key in
147
+ ("spec", "consult", "research", "product", "graph")) + (
148
+ ("fanout", _fanout()),)
377
149
 
378
150
  # the detached auto-index worker (lock-guarded, retrying); same dir as this file
379
151
  INDEX_WORKER = os.path.join(os.path.dirname(os.path.abspath(__file__)),
@@ -390,7 +162,7 @@ CORE_RULES = (
390
162
  ("ponytail", "**Ponytail (minimal code).**"),
391
163
  ("adhd", "**Output shape: ADHD-friendly.**"),
392
164
  ("fidelity", "**Deliver the whole ask; never the shortcut.**"),
393
- ("integrity", '**Integrity: evidence, or "doğrulanmadı".**'),
165
+ ("integrity", '**Integrity: evidence, or a named blocker.**'),
394
166
  ("loop", "**Loop discipline.**"),
395
167
  ("scope", "**Session scope: the user's repo, not tezgah.**"),
396
168
  ("spec", "**Spec before building.**"),
@@ -417,11 +189,12 @@ CORE_RULES = (
417
189
  REMINDER_CLAUSES = (
418
190
  ("exec", "{REPLY_SHORT}, BLUF, "),
419
191
  ("adhd", "answer first - no recap, no closer, at most five ranked items; "),
420
- ("ponytail", "code minimal per ponytail (code first, <=3 note lines); "),
192
+ ("ponytail", "code minimal per ponytail (code first; a note only for a "
193
+ "requested part you cut); "),
421
194
  ("lessons", ".tezgah/lessons.md lines are standing constraints; "),
422
195
  ("consult", "consult before irreversible calls; "),
423
196
  ("integrity", re.compile(r'done/tested claims need observed evidence -> .*?'
424
- r'unverified "done"; ')),
197
+ r'unverified "done"; .*?under "\{BLOCKERS_HEADING\}"; ')),
425
198
  )
426
199
 
427
200
 
@@ -448,12 +221,17 @@ def prompt_reminder(drop=()):
448
221
 
449
222
 
450
223
  def render(text, root=""):
451
- """Fill the path placeholders with stable, existing paths, and the reply
452
- language placeholders with the words config.json's `reply_lang` names."""
224
+ """Fill the path placeholders with stable, existing paths, and the language
225
+ placeholders - the reply language and the contract's own tokens, `{UNVERIFIED}`,
226
+ `{BLOCKERS_HEADING}`, `{DECISION}`... - with the configured language's words
227
+ (`tezgah_paths.say`; REPLY_ANY's reply clause under `any`)."""
453
228
  if not text:
454
229
  return text
455
- for key, words in REPLY_LANG_TEXT[reply_lang()].items():
456
- text = text.replace(key, words)
230
+ words = say()
231
+ if language() == "any":
232
+ words.update(REPLY_ANY)
233
+ for key, word in words.items():
234
+ text = text.replace("{%s}" % key.upper(), word)
457
235
  return (text.replace("{CONSULT_BIN}", tool("consult"))
458
236
  .replace("{CODEGEN_BIN}", tool("codegen"))
459
237
  .replace("{ORX_BIN}", orx_bin() or "orx")
@@ -606,10 +384,12 @@ def git(root, *args):
606
384
 
607
385
  def repo_root(cwd):
608
386
  """The unit of work: the git top-level under a root, else the first path
609
- component below the root (so a non-git project still gets one slug)."""
387
+ component below the root (so a non-git project still gets one slug). Inside
388
+ `.tezgah` git answers the workspace's own repository; the unit is the
389
+ project around it, or the session start sets the project up in `.tezgah`."""
610
390
  top = git(cwd, "rev-parse", "--show-toplevel")
611
391
  if top and under(top):
612
- return os.path.realpath(top)
392
+ return project_top(os.path.realpath(top))
613
393
  base = root_for(cwd)
614
394
  if not base:
615
395
  return os.path.realpath(cwd)
@@ -755,28 +535,49 @@ def open_plans(root):
755
535
 
756
536
 
757
537
  def sibling_line(root):
758
- """One line naming every checkout of this repository with its open plan and
759
- research line counts, or "" when it has one checkout. Fork-free (the pinned
538
+ """One line naming every checkout of this repository, or "" when it has one.
539
+ The main checkout's `.tezgah` is the repository's one workspace (plan 097),
540
+ named with its open plan and research line counts. A linked checkout whose
541
+ own `.tezgah` still holds data is named with what it holds, because `git
542
+ worktree remove` deletes it with the checkout. Fork-free (the pinned
760
543
  session-start git budget): `worktrees` reads `.git/worktrees/*/gitdir`, and
761
- the counts are directory listings. Each checkout keeps its own `.tezgah`;
762
- this only makes the others visible."""
544
+ the counts are directory listings."""
763
545
  checkouts = worktrees(root)
764
546
  here = os.path.realpath(root)
765
547
  if here not in checkouts:
766
548
  checkouts.append(here)
767
549
  if len(checkouts) < 2:
768
550
  return ""
551
+ main = checkouts[0]
769
552
  items = []
770
553
  for checkout in checkouts:
771
554
  ws = workspace(checkout)
772
- state = ("no workspace yet" if not os.path.isdir(ws) else
773
- "%d open plan(s), %d research line(s)"
774
- % (len(glob.glob(os.path.join(ws, "plans", "open", "*.md"))),
775
- len(tezgah_research.slugs(checkout))))
555
+ if checkout == main:
556
+ state = ("no workspace yet" if not os.path.isdir(ws) else
557
+ "%d open plan(s), %d research line(s)"
558
+ % (len(glob.glob(os.path.join(ws, "plans", "open", "*.md"))),
559
+ len(tezgah_research.slugs(checkout))))
560
+ else:
561
+ try:
562
+ # a `.tezgah` that links to the main checkout's is that workspace
563
+ # (review of plan 097, F3): removing the worktree drops the link
564
+ held = ([] if os.path.realpath(ws) == os.path.realpath(workspace(main))
565
+ else sorted(set(os.listdir(ws)) - {".git"}))
566
+ except OSError:
567
+ held = []
568
+ lines = tezgah_research.slugs(checkout) if held else []
569
+ state = ("uses the main checkout's `.tezgah`" if not held else
570
+ "its own `.tezgah` still holds %s, which `git worktree remove` "
571
+ "deletes: move each research line with `%s import %s <slug>` "
572
+ "from the main checkout, and the rest by hand"
573
+ % (", ".join(held), tool("tezgah-research"), checkout))
574
+ if lines:
575
+ state += " (lines: %s)" % ", ".join(lines)
776
576
  items.append("%s (%s%s)" % (checkout, "this checkout, " if checkout == here
777
577
  else "", state))
778
- return ("Worktrees: %d checkouts of this repository, each with its own "
779
- "`.tezgah`: %s. Detail: `%s --all`."
578
+ return ("Worktrees: %d checkouts of this repository share the main checkout's "
579
+ "`.tezgah` - plans, lessons, research and analysis are written there, "
580
+ "never into a worktree's own: %s. Detail: `%s --all`."
780
581
  % (len(checkouts), "; ".join(items), tool("tezgah-research")))
781
582
 
782
583
 
@@ -822,6 +623,13 @@ RESUME_PLAN_CHARS = 40
822
623
  RESUME_COMMIT_CHARS = 38
823
624
  RESUME_CHECK_CHARS = 40
824
625
  RESUME_FILES = 3
626
+ # The slice journal's lines (plan 081): the newest three stopped or open slices
627
+ # and background jobs, each already bounded by the journal and cut again here.
628
+ # They ride the resume block and, because an omp `/clear` fires no
629
+ # session_start, every prompt's `slices` part while any is pending.
630
+ SLICE_LINES = 3
631
+ SLICE_CHARS = 160
632
+ SLICES_HEAD = "Slices left stopped or open (the journal, `tezgah-task slice reconcile`):"
825
633
 
826
634
 
827
635
  def _plan_facts(path):
@@ -863,8 +671,10 @@ def _active_plan(root):
863
671
  report of another plan's State/Next read as the active one is the block
864
672
  inventing a task, which is worse than the line it costs. (None, False) when
865
673
  the repo keeps no open plan."""
866
- paths = sorted(glob.glob(os.path.join(root, ".tezgah", "plans",
867
- "open", "*.md")))
674
+ from tezgah_paths import linked_main
675
+ # a linked worktree's plans are its main checkout's (plan 097)
676
+ paths = sorted(glob.glob(os.path.join(linked_main(root) or root, ".tezgah",
677
+ "plans", "open", "*.md")))
868
678
  if not paths:
869
679
  return None, False
870
680
  branch = git(root, "rev-parse", "--abbrev-ref", "HEAD")
@@ -876,13 +686,33 @@ def _active_plan(root):
876
686
  return paths[0], False
877
687
 
878
688
 
689
+ def slice_lines(root, session_id=None, others=False):
690
+ """This session's pending slices and jobs (`tezgah_task.slice_lines`): at
691
+ most SLICE_LINES lines of at most SLICE_CHARS each, newest first, or []
692
+ with no session, under `slices-off`, without the journal, or when it
693
+ raises - a broken journal costs the lines, never the injection. Another
694
+ session's slices are never listed: they are that session's to resume (omp's
695
+ `--resume` keeps the session id). With `others` - the resume block, read at
696
+ session start and after a compaction - one line counts them, so a session
697
+ that came back under a new id still learns they exist."""
698
+ reader = getattr(tezgah_task, "slice_lines", None)
699
+ if not reader or not session_id or off("slices-off"):
700
+ return []
701
+ try:
702
+ lines = reader(root, limit=SLICE_LINES, session=session_id, others=others) or []
703
+ except Exception:
704
+ return []
705
+ return [cut(str(ln), SLICE_CHARS) for ln in lines[:SLICE_LINES + 1]]
706
+
707
+
879
708
  def resume_state(root, session_id):
880
709
  """The live turn state as a context block, or "" when every part is empty.
881
710
 
882
- The four parts, in order: the active plan's State and Next, the branch's last
711
+ The five parts, in order: the active plan's State and Next, the branch's last
883
712
  RESUME_LOG commits, the newest `verify_ok`/`verify_fail` the ledger holds,
884
- and the files this turn's writes changed. Read by session_start and the
885
- post-compaction path; see the note above for why both."""
713
+ the files this turn's writes changed, and the journal's stopped or open
714
+ slices (`slice_lines`). Read by session_start and the post-compaction path;
715
+ see the note above for why both."""
886
716
  lines = []
887
717
  plan, mine = _active_plan(root)
888
718
  if plan:
@@ -909,7 +739,7 @@ def resume_state(root, session_id):
909
739
  # lessons, pointer - for a session whose ledger holds one bad line. So a
910
740
  # damaged ledger costs these two bullets and nothing else.
911
741
  try:
912
- row = last_check(session_id) if session_id else None
742
+ row = last_check(session_id, root) if session_id else None
913
743
  files = sorted(changed_files(session_id)) if session_id else []
914
744
  except ValueError:
915
745
  row, files = None, []
@@ -919,6 +749,7 @@ def resume_state(root, session_id):
919
749
  RESUME_CHECK_CHARS)))
920
750
  if files:
921
751
  lines.append("- changed this turn: %s" % ", ".join(files[:RESUME_FILES]))
752
+ lines += slice_lines(root, session_id, others=True)
922
753
  if not lines:
923
754
  return ""
924
755
  return "## Session so far\n" + "\n".join(lines)
@@ -1079,11 +910,12 @@ def _lessons_state(root):
1079
910
  lines, tainted_lessons(root))).encode()).hexdigest()[:8]]
1080
911
 
1081
912
 
1082
- def state_stamp(root):
1083
- """The comparable state of the repo this turn is about."""
913
+ def state_stamp(root, ws=None):
914
+ """The comparable state of the repo this turn is about: the checkout's HEAD,
915
+ the plans and lessons of its workspace `ws` (the main checkout's)."""
1084
916
  return {"head": git(root, "rev-parse", "HEAD") or "nogit",
1085
- "plans": _plan_ids(root),
1086
- "lessons": _lessons_state(root)}
917
+ "plans": _plan_ids(ws or root),
918
+ "lessons": _lessons_state(ws or root)}
1087
919
 
1088
920
 
1089
921
  def read_stamp(session_id):
@@ -1194,35 +1026,8 @@ def constraint_notice(cwd, session_id):
1194
1026
  # So the ask is judged per clause: it arms `product` only when no code marker -
1195
1027
  # a file name, an identifier, SQL, a build, test or runtime word - shares its
1196
1028
  # clause (decision 006: qualify the stem, never drop it).
1197
- DATA_ASK = re.compile(
1198
- r"\b(?:(?:design|create|make|build|sketch|propose|need|want)\w* (?:\w+ ){0,4}"
1199
- r"(?:dashboard|charts?|visuali[sz]ation)|"
1200
- r"(?:metri[ck]s?|kpis?|data|sales|revenue|veri\w*|satış\w*|gelir\w*) "
1201
- r"(?:\w+ ){0,2}(?:dashboard|pano)|"
1202
- r"(?:dashboard|gösterge panel)\w* (?:\w+ ){0,4}(?:tasarla|oluştur|hazırla|"
1203
- r"yap|göster)\w*|"
1204
- r"(?:what|which) (?:\w+ ){0,3}(?:metri[ck]s|kpis|charts?) (?:\w+ ){0,3}"
1205
- r"(?:should|to|do|matter|would)|"
1206
- r"what should (?:\w+ ){0,3}dashboard show|"
1207
- r"(?:choose|pick|define|track|design)(?:s|ing)? (?:\w+ ){0,3}(?:metri[ck]s|kpis)|"
1208
- r"(?:metri[ck]s|kpis) (?:should|to|do|would) (?:\w+ ){0,2}"
1209
- r"(?:track|measure|use|watch)|success metrics?|"
1210
- r"(?:suggest|recommend)\w* (?:\w+ ){0,3}charts?|öner\w* (?:\w+ ){0,3}grafi\w*|"
1211
- r"hangi (?:metri[ck]\w*|kpi\w*|grafi\w*|göstergeleri?)|"
1212
- r"(?:metri[ck]\w*|kpi\w*) (?:\w+ ){0,3}(?:tasarla|seç|tanımla|belirle|takip|izle)\w*|"
1213
- r"gösterge panel\w*|visuali[sz]\w* (?:\w+ ){0,3}(?:data|numbers|veri\w*)|"
1214
- r"data visuali[sz]ation|veri\w* (?:\w+ ){0,3}görselleştir\w*)")
1215
- CODE_SENSE = re.compile(
1216
- r"\w\.(?:py|tsx?|jsx?|json|go|rs|ya?ml|sql|java|rb|css|html)\b|\b\w+_\w+\b|"
1217
- r"/metrics\b|\bselect\b.{0,200}?\bfrom\b|\b(?:tables?|columns?|schema|sql|queries|"
1218
- r"query|bundle|webpack|vite|tests?|ci|counters?|registry|pipeline|backend|"
1219
- r"handlers?|structs?|endpoints?|exporters?|export\w*|servers?|librar\w*|"
1220
- r"packages?|modules?|database|index\w*|indeks\w*|"
1221
- r"middleware|debugger|data (?:structure|flow)s?|clipboard|spinner|threads?|"
1222
- r"commit|git|functions?|class|component|prop|hooks?|prometheus|grafana|"
1223
- r"kibana|statsd|opentelemetry|config\w*|provision\w*|api|routes?|snake_case|"
1224
- r"veri yapı\w*|fonksiyon\w*|bileşen\w*|modül\w*|dosya\w*|kopyala\w*|"
1225
- r"seçici\w*|yükleme|ilerleme|paket\w*)\b")
1029
+ DATA_ASK = re.compile(r"\b(?:%s)" % alts("data_ask"))
1030
+ CODE_SENSE = re.compile(alts("code_sense"))
1226
1031
 
1227
1032
 
1228
1033
  def _data_ask(low):
@@ -1458,11 +1263,22 @@ def session_of(payload):
1458
1263
  # ADHD rules inline from the first turn (+1042 B), the fan-out pointer (+112 B)
1459
1264
  # and the `tezgah-skill` line (+113 B) took the healthy fixture's session_start
1460
1265
  # from 10579 B to 12059 B, so at 12000 B a healthy repo gave up its lessons
1461
- # and sibling checkouts to pay for core rules; 13000 B restores the headroom. A
1266
+ # and sibling checkouts to pay for core rules; 13000 B restores the headroom.
1267
+ # Moved again on 2026-10-11 (plan 080): the integrity paragraph carries the
1268
+ # residual rule (the four blockers and the "Engeller" format) and the reminder
1269
+ # its one-line form, +1473 B of core and +151 B of reminder; at 13000 B the
1270
+ # healthy fixture (13588 B) gave up its lessons, plans and steer blocks (950 B;
1271
+ # 1002 B where a subagents note rides along), and at 6000 B the busiest prompt
1272
+ # turn its active-task line (255 B), so
1273
+ # the session budgets move to 14500 B and the prompt budget to 6500 B.
1274
+ # Moved again on 2026-10-12 (plan 086, S3): the integrity paragraph carries
1275
+ # the Ask policy (+913 B of core), and at 14500 B the healthy fixture gave up
1276
+ # its lessons, plans or knowledge block (235-338 B), so the session budgets
1277
+ # move to 15500 B. A
1462
1278
  # budget in bytes, not tokens: this file has no tokenizer and a wrong estimate
1463
1279
  # would be worse than a bound.
1464
- CONTEXT_BUDGET = {"session_start": 13000, "post_compact": 13000,
1465
- "subagent_start": 5500, "user_prompt": 6000}
1280
+ CONTEXT_BUDGET = {"session_start": 15500, "post_compact": 15500,
1281
+ "subagent_start": 5500, "user_prompt": 6500}
1466
1282
  DEFAULT_BUDGET = 13000
1467
1283
  # The blocks in the order they are given up when the budget is exceeded, lowest
1468
1284
  # value first: text another surface already carries (the plan table lives in the
@@ -1477,8 +1293,10 @@ DEFAULT_BUDGET = 13000
1477
1293
  # block, which outlives every status and availability line because it is the only
1478
1294
  # one that says what THIS session was doing (a `git log` and a ledger read are
1479
1295
  # turns the session would otherwise spend) but still yields to a rule - then the
1480
- # scratch-path warning, which is about evidence the turn may already have claimed
1481
- # - then the active task's phase, which outlives both because a phase is what
1296
+ # journal's stopped or open slices, which outlive the resume block because after
1297
+ # an omp `/clear` they are the one pointer left to delegated work in flight - then
1298
+ # the scratch-path warning, which is about evidence the turn may already have
1299
+ # claimed - then the active task's phase, which outlives both because a phase is what
1482
1300
  # stops a refused write before it happens - then the delta, and the skill pointer
1483
1301
  # last. A key absent from this tuple is never dropped: the always-on core, the
1484
1302
  # per-turn reminder and an armed paragraph ARE the rules, and a budget that could
@@ -1487,7 +1305,8 @@ DEFAULT_BUDGET = 13000
1487
1305
  DROP_ORDER = ("knowledge", "worktrees", "lessons", "skill", "lessons_turn",
1488
1306
  "taste", "plans", "subagents", "steer", "orca", "consult", "research",
1489
1307
  "research_broken", "graph", "offnote", "orchestrate",
1490
- "index", "resume", "scratch", "task", "delta", "pointer")
1308
+ "index", "resume", "slices", "context", "scratch", "task", "delta",
1309
+ "pointer")
1491
1310
 
1492
1311
 
1493
1312
  def _first_item(text):
@@ -1609,7 +1428,8 @@ SCRATCH_CHARS = 120
1609
1428
  # while the tool hooks never run - an unloaded plugin, a matcher the host
1610
1429
  # renamed, a hook path that moved. The gate is then off and nothing says so.
1611
1430
  # What the prompt hook can observe is the host's own transcript (Claude and
1612
- # Codex hand `transcript_path` to every hook) and this session's ledger: gated
1431
+ # Codex hand `transcript_path` to every hook, omp's bridge hands its session
1432
+ # file to the prompt hook) and this session's ledger: gated
1613
1433
  # tool calls in the transcript since the session's first turn marker, and not
1614
1434
  # one row from the gate over the same span, is a gate that is not running. Only
1615
1435
  # a bounded tail of the transcript is read, the ledger is scanned as bytes, and
@@ -1618,7 +1438,8 @@ GATE_TAIL = 262144
1618
1438
  GATE_MIN_CALLS = 3
1619
1439
  # The tool names whose every call leaves a PreToolUse row (`began`,
1620
1440
  # tezgah_gate.decision's write and shell branch) and every refused one a `deny`:
1621
- # Claude's write and shell tools and Codex's `exec_command`/`apply_patch`. A
1441
+ # Claude's write and shell tools, omp's `bash`/`edit`/`write` and Codex's
1442
+ # `exec_command`/`apply_patch`. A
1622
1443
  # tool the gate may pass without a row (Read, Grep, Task, WebFetch, an MCP
1623
1444
  # call) is not counted, so its absence from the ledger is never read as a
1624
1445
  # disarmed gate.
@@ -1639,25 +1460,32 @@ def _iso_epoch(value):
1639
1460
  return None
1640
1461
 
1641
1462
 
1642
- def _transcript_calls(path, since):
1643
- """Gated tool calls in the transcript's last GATE_TAIL bytes stamped at or
1644
- after `since` (epoch seconds). Claude writes `{"timestamp", "message":
1645
- {"content": [{"type": "tool_use", "name"}]}}`, Codex `{"timestamp",
1646
- "payload": {"type": "function_call", "name"}}`; anything else counts 0."""
1463
+ def _tail_lines(path):
1464
+ """The raw lines of a `.jsonl` transcript's last GATE_TAIL bytes, the cut
1465
+ first line dropped. [] for any other path or an unreadable file, so a host
1466
+ that hands no transcript pays nothing."""
1647
1467
  if not isinstance(path, str) or not path.endswith(".jsonl"):
1648
- return 0
1468
+ return []
1649
1469
  try:
1650
1470
  with open(path, "rb") as fh:
1651
1471
  size = fh.seek(0, 2)
1652
1472
  fh.seek(max(0, size - GATE_TAIL))
1653
1473
  lines = fh.read().split(b"\n")
1654
1474
  except OSError:
1655
- return 0
1656
- if size > GATE_TAIL:
1657
- lines = lines[1:] # the first line is cut, not a row
1475
+ return []
1476
+ return lines[1:] if size > GATE_TAIL else lines
1477
+
1478
+
1479
+ def _transcript_calls(path, since):
1480
+ """Gated tool calls in the transcript's last GATE_TAIL bytes stamped at or
1481
+ after `since` (epoch seconds). Claude writes `{"timestamp", "message":
1482
+ {"content": [{"type": "tool_use", "name"}]}}`, omp the same with
1483
+ `"toolCall"` blocks, Codex `{"timestamp", "payload": {"type":
1484
+ "function_call", "name"}}`. Anything else counts 0."""
1658
1485
  calls = 0
1659
- for raw in lines:
1660
- if b"tool_use" not in raw and b"function_call" not in raw:
1486
+ for raw in _tail_lines(path):
1487
+ if (b"tool_use" not in raw and b"toolCall" not in raw
1488
+ and b"function_call" not in raw):
1661
1489
  continue
1662
1490
  try:
1663
1491
  row = json.loads(raw)
@@ -1670,7 +1498,7 @@ def _transcript_calls(path, since):
1670
1498
  msg = row.get("message")
1671
1499
  if isinstance(msg, dict) and isinstance(msg.get("content"), list):
1672
1500
  names = [b.get("name") for b in msg["content"]
1673
- if isinstance(b, dict) and b.get("type") == "tool_use"]
1501
+ if isinstance(b, dict) and b.get("type") in ("tool_use", "toolCall")]
1674
1502
  pay = row.get("payload")
1675
1503
  if isinstance(pay, dict) and pay.get("type") == "function_call":
1676
1504
  names.append(pay.get("name"))
@@ -1678,10 +1506,72 @@ def _transcript_calls(path, since):
1678
1506
  return calls
1679
1507
 
1680
1508
 
1509
+ # The usage keys that make up one step's input side: Claude's names, then omp's.
1510
+ # Codex reports the cached share inside `input_tokens`.
1511
+ INPUT_KEYS = ("input_tokens", "cache_creation_input_tokens",
1512
+ "cache_read_input_tokens", "input", "cacheRead", "cacheWrite")
1513
+ CONTEXT_NUDGE = (
1514
+ "Router context %dk tokens: push heavy reads and check output through a "
1515
+ "slice that returns the exit code and the failing lines. Hand the open "
1516
+ "work to a slice or run `/compact` before the next heavy step.")
1517
+ # The row a compaction leaves: omp's `{"type": "compaction"}`, Claude's
1518
+ # `compact_boundary` system row. A step before it measured the old context.
1519
+ COMPACTED = re.compile(rb'"(?:type"\s*:\s*"compaction|subtype"\s*:\s*"compact_boundary)"')
1520
+
1521
+
1522
+ def context_tokens(path):
1523
+ """Input-side tokens of the newest main-thread assistant step in the
1524
+ transcript tail, or None. Claude and omp put `usage` on the assistant
1525
+ message (a Claude `isSidechain` row is a subagent's), Codex on a
1526
+ `token_count` event's `last_token_usage`. A compaction row met first means
1527
+ no step since the compaction, so None."""
1528
+ for raw in reversed(_tail_lines(path)):
1529
+ if COMPACTED.search(raw):
1530
+ return None
1531
+ if b"usage" not in raw:
1532
+ continue
1533
+ try:
1534
+ row = json.loads(raw)
1535
+ except ValueError:
1536
+ continue
1537
+ if not isinstance(row, dict) or row.get("isSidechain"):
1538
+ continue
1539
+ msg, pay = row.get("message"), row.get("payload")
1540
+ usage = None
1541
+ if isinstance(msg, dict) and msg.get("role") == "assistant":
1542
+ usage = msg.get("usage")
1543
+ elif isinstance(pay, dict) and pay.get("type") == "token_count":
1544
+ usage = (pay.get("info") or {}).get("last_token_usage")
1545
+ if isinstance(usage, dict):
1546
+ n = sum(v for k, v in usage.items() if k in INPUT_KEYS and isinstance(v, int))
1547
+ if n:
1548
+ return n
1549
+ return None
1550
+
1551
+
1552
+ def context_key(session_id):
1553
+ """The router-context line's key in the `nudged` mark store."""
1554
+ return "context:" + slug(str(session_id))
1555
+
1556
+
1557
+ def context_nudge(payload, session_id):
1558
+ """The one line asking the router to delegate heavy reading, or "" below
1559
+ CONTEXT_NUDGE_TOKENS, without a transcript, under `orchestrate-off`, or
1560
+ once `context_for` latched it for this session (cleared by a compaction)."""
1561
+ path = payload.get("transcript_path") if isinstance(payload, dict) else None
1562
+ if not path or off("orchestrate-off") or tezgah_store.marked(
1563
+ "nudged", context_key(session_id)):
1564
+ return ""
1565
+ tokens = context_tokens(path)
1566
+ return CONTEXT_NUDGE % (tokens // 1000) if (
1567
+ tokens or 0) >= CONTEXT_NUDGE_TOKENS else ""
1568
+
1569
+
1681
1570
  # The ledger kinds a hook other than the tool hooks writes: the prompt hook's
1682
1571
  # turn marker, judge row, lesson rows and disarm row, the Stop hook's claim,
1683
1572
  # refusal, after_block, shape and stop_spec rows, the subagent-end hook's subagent_end row,
1684
- # compaction, the subagent mark (SubagentStart writes it too), the guard's crash
1573
+ # compaction, the subagent mark and the `subagent_start` lineage row (both from
1574
+ # SubagentStart), the omp fork's `forked` row, the guard's crash
1685
1575
  # row (any hook), the damage row any reader writes and the session start's
1686
1576
  # attest row. Every other kind - deny, nudge, began, snapshot, ... - can only
1687
1577
  # come from a tool hook.
@@ -1691,8 +1581,8 @@ def _transcript_calls(path, since):
1691
1581
  NOT_TOOL_HOOK = frozenset((b"turn", b"judge", b"claim", b"refusal",
1692
1582
  b"after_block", b"subagent_end", b"stop_spec",
1693
1583
  b"shape", b"compact", b"orch", b"crash", b"route",
1694
- b"spawned", b"lesson", b"disarm", b"ledger_damage",
1695
- b"attest"))
1584
+ b"spawned", b"forked", b"lesson", b"disarm", b"ledger_damage",
1585
+ b"attest", b"subagent_start"))
1696
1586
  # The rows PostToolUse writes (`note_tool`): they prove the host ran the call,
1697
1587
  # not that the gate saw it - a PostToolUse hook keeps writing them while a
1698
1588
  # broken PreToolUse hook lets every call through - so they are no proof either.
@@ -1828,15 +1718,31 @@ POINTER_NEEDLE = "tezgah-contract"
1828
1718
  # constraint that the user never meant is a rule the session did not get from
1829
1719
  # the user. tests/constraint-fixtures.md holds the real prompts it was measured
1830
1720
  # on, its false positives listed.
1831
- CONSTRAINT_SHAPES = re.compile(
1832
- r"(?i)\b(?:(?:do not|don'?t|never)\s+(?:touch|modify|edit|change|delete|"
1833
- r"remove|rename|push|commit|merge|deploy|publish|release)\s+"
1834
- r"(?:(?:the|any|my|this|that|to|a|an)\s+)?[`\"“]?"
1835
- r"(?P<obj>[\w~@/-](?:[\w./@~-]*[\w@~/-])?)"
1836
- r"|ask\s+(?:me\s+)?(?:first\s+)?before\s+(?P<ask>\w+)"
1837
- r"|(?P<tr>[\w./@~-]+?)(?:'\w+)?\s+(?:sakın\s+)?dokunma(?:yın|yınız)?\b"
1838
- r"(?!\s+(?:hedef|alan|olay|duyar)\w*)"
1839
- r"|(?:bana\s+)?sormadan\s+(?P<trask>\w+)\s+(?:etme|yapma)\w*)")
1721
+ def _constraint_shapes():
1722
+ """Every pack's `constraint.dont` and `constraint.ask` shapes in one
1723
+ pattern, each named group suffixed with its pack's code (`obj_en`,
1724
+ `ask_tr`), so `_shape` can tell which language's shape matched."""
1725
+ return re.compile(r"(?i)\b(?:%s)" % "|".join(
1726
+ frag.replace("(?P<obj>", "(?P<obj_%s>" % code).replace(
1727
+ "(?P<ask>", "(?P<ask_%s>" % code)
1728
+ for code, pack in packs().items() for kind in ("dont", "ask")
1729
+ for frag in pack["constraint"][kind]))
1730
+
1731
+
1732
+ CONSTRAINT_SHAPES = _constraint_shapes()
1733
+
1734
+
1735
+ def _shape(m):
1736
+ """(kind, group, pack) of a CONSTRAINT_SHAPES match: `obj` for a
1737
+ prohibition's object, `ask` for an ask-first clause's verb. Read off the
1738
+ one named group that matched (`groupdict`), so a pack fragment's own
1739
+ groups cannot stand in for it (tests/test_lang_packs.py holds each
1740
+ fragment to its one named group)."""
1741
+ group = next(k for k, v in m.groupdict().items() if v is not None)
1742
+ kind, code = group.split("_", 1)
1743
+ return kind, group, packs()[code]
1744
+
1745
+
1840
1746
  # Pasted material is not the user's constraint: a shape that starts inside a
1841
1747
  # quoted span, inline code, a fenced block or a `>` quote line is skipped (a
1842
1748
  # quoted object after an unquoted shape stays the user's), and a prompt
@@ -1846,11 +1752,12 @@ CONSTRAINT_SHAPES = re.compile(
1846
1752
  # log alone matched six clauses of another agent's reasoning. ponytail: a long
1847
1753
  # prompt the user did write loses its constraints - the cost of a length rule.
1848
1754
  CONSTRAINT_PROMPT_MAX = 12000
1755
+ # Single quotes count only off a word ("main'e" is no quote), with ‘…’ and «…».
1849
1756
  QUOTED = re.compile(r"```.*?```|`[^`\n]*`|\"[^\"\n]{0,200}\"|“[^”\n]{0,200}”"
1757
+ r"|(?<!\w)'[^'\n]{1,200}'(?!\w)|‘[^’\n]{0,200}’|«[^»\n]{0,200}»"
1850
1758
  r"|^\s*>.*$", re.S | re.M)
1851
1759
  SENTENCE_END = re.compile(r"[.!?;:](?=\s|$)|\n")
1852
- NEEDLE_STOP = frozenset("while in on at without until unless and or for to with "
1853
- "before after from into of but ve veya ile".split())
1760
+ NEEDLE_STOP = frozenset(w for p in packs().values() for w in p["needle_stop"])
1854
1761
  QUOTE_MARKS = re.compile(r"[`\"“”]")
1855
1762
  NEEDLE_WORDS = 3
1856
1763
  CONSTRAINT_CHARS = 120
@@ -1860,8 +1767,8 @@ CONSTRAINTS_MAX = 5
1860
1767
  def user_constraints(prompt):
1861
1768
  """The constraint clauses a prompt issues, redacted and cut, in order and
1862
1769
  once each. A clause runs from the shape to the end of its sentence - from the
1863
- sentence's start for the Turkish forms, whose object comes first. The stamp
1864
- keeps the clause, never the prompt."""
1770
+ sentence's start for an `object_first` pack's prohibition (Turkish), whose
1771
+ object comes first. The stamp keeps the clause, never the prompt."""
1865
1772
  text = prompt or ""
1866
1773
  if len(text) > CONSTRAINT_PROMPT_MAX:
1867
1774
  return []
@@ -1870,8 +1777,11 @@ def user_constraints(prompt):
1870
1777
  for m in CONSTRAINT_SHAPES.finditer(text):
1871
1778
  if any(a <= m.start() < b for a, b in quoted):
1872
1779
  continue
1780
+ kind, _group, pack = _shape(m)
1873
1781
  start = (max((e.end() for e in SENTENCE_END.finditer(text, 0, m.start())),
1874
- default=0) if m.group("tr") else m.start())
1782
+ default=0)
1783
+ if kind == "obj" and pack["word_order"] == "object_first"
1784
+ else m.start())
1875
1785
  end = SENTENCE_END.search(text, m.end())
1876
1786
  clause = " ".join(text[start:end.start() if end else len(text)].split())
1877
1787
  clause = cut(redact(clause.strip("-*• ")), CONSTRAINT_CHARS)
@@ -1879,6 +1789,97 @@ def user_constraints(prompt):
1879
1789
  out.append(clause)
1880
1790
  return out
1881
1791
 
1792
+ # A grant is the positive half of the same reader (plan 086, S3): "bana sormadan
1793
+ # pushla", "without asking", "onay veriyorum" lets the session take an outward
1794
+ # step without asking again; "artık bana sor", "ask me again" or a later
1795
+ # prohibition that names the step ("bana sormadan push etme", "don't push")
1796
+ # lifts it. Each is one `grant` row in the session's ledger, scoped to the
1797
+ # OUTWARD class the sentence names, `all` when it names none; the Stop rule's
1798
+ # `needless ask` and the ask-tool warning read them back
1799
+ # (`tezgah_integrity.grants_in_force`). Never a class-F step: a grant lowers
1800
+ # asks, it never licenses a force push, a delete or a spend over the cap.
1801
+ LIFT_SHAPES = re.compile(r"(?i)\b(?:%s)" % alts("constraint", "lift"))
1802
+ # A negated outward step ("Don't push.", "şimdilik push yapma") lifts only the
1803
+ # class its own words name, never `all`.
1804
+ LIFT_OUTWARD = re.compile(r"(?i)\b(?:%s)" % alts("constraint", "lift_outward"))
1805
+ # A grant shape in a sentence that forbids, complains or asks is no grant:
1806
+ # "bana sormadan hiçbir şey yapma", "neden bana sormadan pushladın?", "Never
1807
+ # tag without asking me". Each pack's rules read only the sentences its own
1808
+ # grant shape matched, so "theme" is no Turkish -me: `grant_not` anywhere in
1809
+ # the sentence with the grant shape blanked ("don't ask me again" stays a
1810
+ # grant), the pack's question particle, and `grant_not_verb` only on the word
1811
+ # right after the grant shape or the sentence's last word - one of
1812
+ # `grant_not_soft` ("durma", "unutma") only after an outward word. ponytail: a
1813
+ # Turkish noun ending in -ma/-me in those two places also reads as a
1814
+ # prohibition - a missed grant costs one question, a false one an unasked push.
1815
+
1816
+
1817
+ def _any(frags):
1818
+ return re.compile(r"(?i)(?:%s)" % ("|".join(frags) or "(?!)"))
1819
+
1820
+
1821
+ GRANT_PACKS = [(re.compile(r"(?i)\b(?:%s)" % "|".join(p["constraint"]["grant"])),
1822
+ _any(p["constraint"]["grant_not"]
1823
+ + [r"\b(?:%s)\b" % q for q in p["question_end"]]),
1824
+ _any(p["constraint"]["grant_not_verb"]),
1825
+ _any(p["constraint"]["grant_not_soft"]))
1826
+ for p in packs().values() if p["constraint"]["grant"]]
1827
+ WORD = re.compile(r"[^\W\d_]+")
1828
+
1829
+
1830
+ def _refuses(text, start, at, to, stop, nots, verb, soft):
1831
+ """True when the sentence `text[start:stop]` takes back the grant shape
1832
+ at `text[at:to]` (GRANT_PACKS' rules of the pack that matched it)."""
1833
+ if text[stop:stop + 1] == "?" or nots.search(text[start:at] + " " + text[to:stop]):
1834
+ return True
1835
+ words = [m for m in WORD.finditer(text, start, stop) if not at <= m.start() < to]
1836
+ near = [m for m in words if m.start() >= to][:1] + words[-1:]
1837
+ return any(verb.fullmatch(m.group(0)) and not (
1838
+ soft.fullmatch(m.group(0)) and not outward_classes(text[start:m.start()]))
1839
+ for m in near)
1840
+
1841
+
1842
+ def user_grants(prompt):
1843
+ """[(clause, target)] for every grant and lift a prompt issues, in order:
1844
+ `target` is an OUTWARD class or `all`, `lift:` before it for a lift. A
1845
+ sentence is read whole for the class it names, since a Turkish grant often
1846
+ names the step before "sormadan"; a grant its sentence takes back
1847
+ (`_refuses`) is none, a lift shape inside a grant shape ("don't ask me
1848
+ again") is the grant's, and a LIFT_OUTWARD match lifts only the classes it
1849
+ names. Pasted material and an over-long prompt are skipped as
1850
+ `user_constraints` skips them."""
1851
+ text = prompt or ""
1852
+ if len(text) > CONSTRAINT_PROMPT_MAX:
1853
+ return []
1854
+ quoted = [q.span() for q in QUOTED.finditer(text)]
1855
+ found = [(m.start(), m.end(), rules) for shapes, *rules in GRANT_PACKS
1856
+ for m in shapes.finditer(text)]
1857
+ grants = [(a, b) for a, b, _r in found]
1858
+ found += [(m.start(), m.end(), kind) for kind, shapes in
1859
+ (("lift", LIFT_SHAPES), ("outward", LIFT_OUTWARD))
1860
+ for m in shapes.finditer(text)
1861
+ if not any(a <= m.start() < b for a, b in grants)]
1862
+ out = []
1863
+ for at, to, rules in sorted(found, key=lambda f: f[:2]):
1864
+ if any(a <= at < b for a, b in quoted):
1865
+ continue
1866
+ lift = isinstance(rules, str)
1867
+ start = max((e.end() for e in SENTENCE_END.finditer(text, 0, at)), default=0)
1868
+ end = SENTENCE_END.search(text, to)
1869
+ stop = end.start() if end else len(text)
1870
+ if not lift and _refuses(text, start, at, to, stop, *rules):
1871
+ continue
1872
+ sentence = " ".join(text[start:stop].split())
1873
+ clause = cut(redact(sentence.strip("-*• ")), CONSTRAINT_CHARS)
1874
+ classes = (sorted(outward_classes(text[at:to])) if rules == "outward"
1875
+ else sorted(outward_classes(sentence)) or ["all"])
1876
+ for cls in classes:
1877
+ out.append((clause, ("lift:" if lift else "") + cls))
1878
+ for clause in user_constraints(text):
1879
+ out += [(clause, "lift:" + cls) for cls in sorted(outward_classes(clause))]
1880
+ return list(dict.fromkeys(out))
1881
+
1882
+
1882
1883
 
1883
1884
  def constraint_needle(clause):
1884
1885
  """The fragment of a constraint a summary keeps when it kept the constraint:
@@ -1900,13 +1901,14 @@ def constraint_needle(clause):
1900
1901
  if len(kept) == NEEDLE_WORDS or bare != word:
1901
1902
  break
1902
1903
  return kept
1903
- if m.group("obj"):
1904
- needle = " ".join(phrase(clause[m.start("obj"):].split()))
1905
- elif m.group("tr"):
1906
- before = phrase(reversed(clause[:m.end("tr")].split()))
1904
+ kind, group, pack = _shape(m)
1905
+ if kind == "ask":
1906
+ needle = m.group(group)
1907
+ elif pack["word_order"] == "object_first":
1908
+ before = phrase(reversed(clause[:m.end(group)].split()))
1907
1909
  needle = " ".join(reversed(before)).split("'")[0]
1908
1910
  else:
1909
- needle = m.group("ask") or m.group("trask")
1911
+ needle = " ".join(phrase(clause[m.start(group):].split()))
1910
1912
  return needle or clause
1911
1913
 
1912
1914
 
@@ -1980,6 +1982,11 @@ def context_for(event, cwd, payload=None, with_core=True):
1980
1982
  if not under(cwd):
1981
1983
  return None
1982
1984
  root = repo_root(cwd)
1985
+ # The repository's one workspace: a linked worktree reads and creates the
1986
+ # main checkout's `.tezgah`, where `git worktree remove` cannot take it and
1987
+ # where bin/tezgah-task already keeps the plans (plan 097).
1988
+ from tezgah_paths import linked_main
1989
+ ws = linked_main(root) or root
1983
1990
  ACTIVE_ROOT[0] = root_for(cwd) or ""
1984
1991
  # What the compaction kept, recorded before the block is built: the summary
1985
1992
  # is the host's own text and this path is the only place it is handed over, so
@@ -1993,6 +2000,8 @@ def context_for(event, cwd, payload=None, with_core=True):
1993
2000
  if event == "post_compact" or (event == "session_start" and isinstance(
1994
2001
  payload, dict) and payload.get("source") == "compact"):
1995
2002
  forget_seen(session_of(payload))
2003
+ # the context line may fire again on the compacted context
2004
+ tezgah_store.drop("nudged", context_key(session_of(payload)))
1996
2005
  # The switch latch: every off() below answers for this session, and a
1997
2006
  # prompt's turn marker (with its `authorized` row) is written first, so a
1998
2007
  # switch the user's prompt names is honored from this very turn.
@@ -2023,6 +2032,13 @@ def context_for(event, cwd, payload=None, with_core=True):
2023
2032
  # even when the per-turn text is off. Never in DROP_ORDER, so no budget
2024
2033
  # gives it up - it says the rules below are not being enforced.
2025
2034
  gate = gate_inactive(session_id, payload)
2035
+ # The user's grants and lifts (`user_grants`), before the reminder
2036
+ # switch too: the Stop rule reads them whether or not this turn's text
2037
+ # is shown. A subagent's prompt is its parent's brief, never the
2038
+ # user's grant (omp's bridge sends `subagent`).
2039
+ for clause, target in ([] if isinstance(payload, dict) and payload.get("subagent")
2040
+ else user_grants(prompt)):
2041
+ note(session_id, GRANT_KIND, clause, target=target)
2026
2042
  # per-turn nudge: openers decay over long sessions. Kept short because
2027
2043
  # it is paid every turn, and on Claude the output style already carries
2028
2044
  # the same rules on every response. The conditional rules ride along
@@ -2061,7 +2077,7 @@ def context_for(event, cwd, payload=None, with_core=True):
2061
2077
  # only here: a conditional paragraph reaches no static file. The
2062
2078
  # one-line repeat carries it too: it is a fact, not rule text.
2063
2079
  parts.append(("armed", render("\n\n".join(armed).replace(
2064
- "{OPEN_LINES}", open_lines_note(root)))))
2080
+ "{OPEN_LINES}", open_lines_note(ws)))))
2065
2081
  else:
2066
2082
  audit_classification(set(), 0)
2067
2083
  # One cheap judgement in front of the skill choice: the roster reaches the
@@ -2084,7 +2100,7 @@ def context_for(event, cwd, payload=None, with_core=True):
2084
2100
  # state_delta/constraint_notice), and the glance says whether the graph
2085
2101
  # is behind that move - a comparison that used to end in a status glyph
2086
2102
  # the model never reads.
2087
- stamp = state_stamp(root)
2103
+ stamp = state_stamp(root, ws)
2088
2104
  delta = state_delta(root, previous, stamp)
2089
2105
  if delta:
2090
2106
  parts.append(("delta", delta))
@@ -2097,8 +2113,8 @@ def context_for(event, cwd, payload=None, with_core=True):
2097
2113
  if (previous or {}).get("root") == root else []
2098
2114
  relevant, keys = "", []
2099
2115
  if prompt and ".no-lessons" not in repo_marks(cwd)[1]:
2100
- relevant, keys = relevant_lessons(root, prompt, seen)
2101
- if relevant and workspace_from_repo(root):
2116
+ relevant, keys = relevant_lessons(ws, prompt, seen)
2117
+ if relevant and workspace_from_repo(ws):
2102
2118
  relevant, keys = (("", []) if "provided" in seen else
2103
2119
  (repo_provided(".tezgah/lessons.md"), ["provided"]))
2104
2120
  if relevant:
@@ -2118,6 +2134,26 @@ def context_for(event, cwd, payload=None, with_core=True):
2118
2134
  if scratch:
2119
2135
  command = str(scratch.get("detail") or "").strip()
2120
2136
  parts.append(("scratch", SCRATCH_REMINDER % command[:SCRATCH_CHARS]))
2137
+ # The journal's stopped or open slices and background jobs: an omp
2138
+ # `/clear` fires no session_start, so the resume block never reaches the
2139
+ # next turn and this part is the one that names the work left in flight.
2140
+ pending = slice_lines(root, session_id)
2141
+ if pending:
2142
+ parts.append(("slices", "\n".join([SLICES_HEAD] + pending)))
2143
+ # The router's own context: above the threshold, one line asks it to
2144
+ # push heavy reading through a slice (orchestrator-flow-audit S2 F1).
2145
+ heavy = context_nudge(payload, session_id)
2146
+ if heavy:
2147
+ parts.append(("context", heavy))
2148
+ # a long session outlives a provider's reset: the same due check as
2149
+ # session start (one small JSON read), so a family rejoins omp's chain
2150
+ # without waiting for the next session
2151
+ try:
2152
+ import tezgah_models
2153
+ if tezgah_models.health_due():
2154
+ tezgah_models.start_health()
2155
+ except Exception:
2156
+ pass
2121
2157
  stale = index_notice(cwd)
2122
2158
  if stale:
2123
2159
  parts.append(("index", stale))
@@ -2125,6 +2161,12 @@ def context_for(event, cwd, payload=None, with_core=True):
2125
2161
  parts.append(("offnote", "(off this session: %s)"
2126
2162
  % ", ".join(disabled)))
2127
2163
  text = budgeted(event, parts)
2164
+ # latched only when the budget kept it, so a dropped line comes back
2165
+ if heavy and heavy in text:
2166
+ try:
2167
+ tezgah_store.mark("nudged", context_key(session_id))
2168
+ except tezgah_store.ERRORS:
2169
+ pass # fail open: an unwritable store shows the line again
2128
2170
  items = [ln for ln in relevant.split("\n") if ln.startswith("- ")]
2129
2171
  shown = ([k for k, ln in zip(keys, items) if ln in text] if items
2130
2172
  else keys if relevant and relevant in text else [])
@@ -2189,10 +2231,19 @@ def context_for(event, cwd, payload=None, with_core=True):
2189
2231
  "research to a host subagent and say the tooling is "
2190
2232
  "unavailable; do not improvise its protocol."))
2191
2233
  if event == "session_start":
2192
- note = sync_agents(root)
2193
- if note:
2234
+ synced = sync_agents(root)
2235
+ if synced:
2194
2236
  parts.append(("subagents",
2195
- "Subagents (this repo, generated): %s" % note))
2237
+ "Subagents (this repo, generated): %s" % synced))
2238
+ # the provider health behind omp's subagent chains: re-probed in the
2239
+ # background at most every few hours or once a recorded reset passes, so
2240
+ # a family at its limit leaves the chain and rejoins it after the reset
2241
+ try:
2242
+ import tezgah_models
2243
+ if tezgah_models.health_due():
2244
+ tezgah_models.start_health()
2245
+ except Exception:
2246
+ pass
2196
2247
  # orchestrate-off says "do not delegate", so no line may name delegates
2197
2248
  steer = (None if off("orchestrate-off")
2198
2249
  else steering(root, (payload or {}).get("host")))
@@ -2202,8 +2253,8 @@ def context_for(event, cwd, payload=None, with_core=True):
2202
2253
  # The workspace every per-project artifact lands in: created, ignored by
2203
2254
  # the project and given its private git before anything writes there.
2204
2255
  # A root is not a project, and the helper returns None outside a work tree.
2205
- if root not in roots():
2206
- ensure_workspace(root)
2256
+ if ws not in roots():
2257
+ ensure_workspace(ws)
2207
2258
  # The live turn state, first in this region: on a compacted or resumed
2208
2259
  # session it is the one thing the rest of the block cannot re-derive.
2209
2260
  resume = resume_state(root, session_of(payload))
@@ -2219,9 +2270,9 @@ def context_for(event, cwd, payload=None, with_core=True):
2219
2270
 
2220
2271
  def provided():
2221
2272
  if not from_repo:
2222
- from_repo.append(workspace_from_repo(root))
2273
+ from_repo.append(workspace_from_repo(ws))
2223
2274
  return from_repo[0]
2224
- plans = open_plans(root)
2275
+ plans = open_plans(ws)
2225
2276
  if plans:
2226
2277
  parts.append(("plans", repo_provided(".tezgah/plans/open/")
2227
2278
  if provided() else plans))
@@ -2236,7 +2287,7 @@ def context_for(event, cwd, payload=None, with_core=True):
2236
2287
  # The project's own rule files, agents and skills (tezgah-migrate's
2237
2288
  # index): they stay where the project keeps them, and one line makes a
2238
2289
  # session read the rows its task touches instead of never seeing them.
2239
- if os.path.isfile(os.path.join(root, ".tezgah", "analysis",
2290
+ if os.path.isfile(os.path.join(ws, ".tezgah", "analysis",
2240
2291
  "project-knowledge.md")):
2241
2292
  parts.append(("knowledge",
2242
2293
  "Project knowledge: this repo keeps its own rule files, "
@@ -2245,14 +2296,14 @@ def context_for(event, cwd, payload=None, with_core=True):
2245
2296
  "the task touches before starting; a nested AGENTS.md or "
2246
2297
  "CLAUDE.md binds the subtree it sits in."))
2247
2298
  if ".no-lessons" not in marks:
2248
- past = lessons(root)
2299
+ past = lessons(ws)
2249
2300
  if past:
2250
2301
  parts.append(("lessons", repo_provided(".tezgah/lessons.md")
2251
2302
  if provided() else past))
2252
2303
  if not provided():
2253
- recent = lesson_lines(root)[-LESSON_LINES:]
2304
+ recent = lesson_lines(ws)[-LESSON_LINES:]
2254
2305
  injected = list(zip(recent, _lesson_shown(
2255
- recent, tainted_lessons(root))))
2306
+ recent, tainted_lessons(ws))))
2256
2307
  # The opt-in taste learnings (hooks/tezgah_taste_ledger.block): the
2257
2308
  # active ones as rules or hints, read from the ledger with no call out;
2258
2309
  # off, `enabled` costs the marker stat. The injection is recorded below,
@@ -2278,7 +2329,7 @@ def context_for(event, cwd, payload=None, with_core=True):
2278
2329
  # bulk import at most daily (tezgah_store.import_later).
2279
2330
  if event == "session_start":
2280
2331
  tezgah_store.import_later(cache_dir())
2281
- broken = tezgah_research.failing(root) if not off("research-off") else []
2332
+ broken = tezgah_research.failing(ws) if not off("research-off") else []
2282
2333
  if broken:
2283
2334
  line_slug, err = broken[0]
2284
2335
  parts.append(("research_broken",