@r1z4x/tezgah 1.5.1 → 1.7.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 (81) hide show
  1. package/CHANGELOG.md +126 -1
  2. package/MANIFEST +11 -0
  3. package/README.md +6 -3
  4. package/bin/consult +13 -10
  5. package/bin/tezgah-docs +18 -16
  6. package/bin/tezgah-doctor +152 -0
  7. package/bin/tezgah-research +286 -12
  8. package/bin/tezgah-route +48 -1
  9. package/bin/tezgah-setup +27 -14
  10. package/bin/tezgah-task +771 -46
  11. package/bin/tezgah-taste +48 -10
  12. package/docs/architecture.md +7 -4
  13. package/docs/citations-baseline.json +1 -1
  14. package/docs/clarity-baseline.json +5 -5
  15. package/docs/contract.md +112 -25
  16. package/docs/evidence.md +201 -28
  17. package/docs/feature-audit.md +25 -4
  18. package/docs/gate.md +70 -33
  19. package/docs/glossary.md +4 -4
  20. package/docs/hosts.md +8 -8
  21. package/docs/index.json +30 -10
  22. package/docs/judge.md +9 -3
  23. package/docs/models.md +35 -2
  24. package/docs/operations.md +103 -3
  25. package/docs/research.md +392 -13
  26. package/docs/skills.md +24 -6
  27. package/docs/status-line.md +5 -5
  28. package/docs/testing.md +2 -2
  29. package/docs/vision.md +12 -4
  30. package/hooks/hooks.json +2 -2
  31. package/hooks/lang/en.json +323 -0
  32. package/hooks/lang/tr.json +344 -0
  33. package/hooks/projects-posttooluse.py +24 -5
  34. package/hooks/projects-pretooluse.py +9 -4
  35. package/hooks/projects-stop.py +28 -16
  36. package/hooks/tezgah_agents.py +29 -9
  37. package/hooks/tezgah_clarity.py +7 -3
  38. package/hooks/tezgah_components.py +2 -1
  39. package/hooks/tezgah_context.py +460 -390
  40. package/hooks/tezgah_gate.py +640 -104
  41. package/hooks/tezgah_integrity.py +1480 -197
  42. package/hooks/tezgah_models.py +337 -15
  43. package/hooks/tezgah_paths.py +103 -10
  44. package/hooks/tezgah_policy.py +286 -130
  45. package/hooks/tezgah_product.py +107 -0
  46. package/hooks/tezgah_rank.py +29 -27
  47. package/hooks/tezgah_replay.py +200 -30
  48. package/hooks/tezgah_research.py +1297 -12
  49. package/hooks/tezgah_skill_pick.py +58 -17
  50. package/hooks/tezgah_stopspec.py +7 -0
  51. package/hooks/tezgah_store.py +74 -27
  52. package/hooks/tezgah_task.py +917 -8
  53. package/hosts/codex/hook.py +9 -10
  54. package/hosts/cursor/hook.py +21 -14
  55. package/hosts/dsh/hooks.json +2 -2
  56. package/hosts/omp/hook.py +44 -23
  57. package/hosts/omp/tezgah-hook.ts.in +93 -7
  58. package/hosts/opencode/plugins/tezgah.js +119 -39
  59. package/output-styles/tezgah.md +76 -42
  60. package/package.json +1 -1
  61. package/skills/analyze-app/SKILL.md +57 -0
  62. package/skills/design-contract/EVALS.md +24 -1
  63. package/skills/design-contract/SKILL.md +17 -1
  64. package/skills/design-contract/data-views.md +86 -0
  65. package/skills/feature-audit/SKILL.md +32 -3
  66. package/skills/i-have-adhd/EVALS.md +22 -2
  67. package/skills/i-have-adhd/SKILL.md +12 -5
  68. package/skills/plan-add/EVALS.md +8 -8
  69. package/skills/plan-add/SKILL.md +22 -21
  70. package/skills/plan-sync/EVALS.md +2 -2
  71. package/skills/plan-sync/SKILL.md +14 -8
  72. package/skills/pm-frameworks/SKILL.md +6 -0
  73. package/skills/ponytail/EVALS.md +21 -2
  74. package/skills/ponytail/SKILL.md +4 -4
  75. package/skills/product-analysis/EVALS.md +23 -1
  76. package/skills/product-analysis/SKILL.md +50 -11
  77. package/skills/product-flow/EVALS.md +81 -0
  78. package/skills/product-flow/SKILL.md +91 -0
  79. package/skills/research/SKILL.md +99 -2
  80. package/skills/tezgah-contract/EVALS.md +52 -7
  81. package/skills/tezgah-contract/SKILL.md +204 -85
@@ -19,20 +19,21 @@ 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, root_for, roots, say, tool,
36
37
  workspace, workspace_from_repo, worktrees, writable_dir)
37
38
 
38
39
  try: # The task record is the active plan's frontmatter (see tezgah_task), read
@@ -55,325 +56,95 @@ except ImportError: # pragma: no cover - only where the module has not landed
55
56
 
56
57
  # A prompt that matches one of these arms the matching conditional rule for that
57
58
  # 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.
59
+ # patterns are reviewable. The words are data: each language pack
60
+ # (hooks/lang/<code>.json, `hints.<key>`) lists its own half, and `_hints`
61
+ # joins every pack's into one pattern per key, so a prompt in any shipped
62
+ # language arms the same rules. Word-ish boundaries keep "deploy" from firing
63
+ # inside an identifier.
60
64
  #
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".
65
+ # Turkish is agglutinative and its hints are verb/noun stems, so an
66
+ # English-style trailing \b would kill the most natural phrasing (düzgün
67
+ # çalışsın, hipotezi test et): such a fragment carries \w* where a suffix can
68
+ # land, which \b then closes. \w is Unicode-aware. A whole word keeps its plain
69
+ # boundary, so "deney" still does not fire on "deneyim".
66
70
  #
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
71
+ # The craft words in the spec class that are ALSO ordinary nouns are qualified
72
+ # by a UI object rather than matched bare (a pack fragment `{"ui_ask": word}`):
73
+ # the bare word armed the paragraph on asks that had no UI in them at all.
74
+ # `_ui_ask` expands one in the pack's own word order - a `verb_first` pack (en)
75
+ # takes a UI object beside the word or a quality word after it, an
76
+ # `object_first` pack (tr: "adım akışını sadeleştir" is a UI ask, "bu
77
+ # fonksiyonu sadeleştir" a refactor) only an object before it - and both halves
78
+ # are pinned in tests/test_hint_coverage.py. Every object is word-bounded, so
79
+ # `app` cannot match inside `happen`.
80
+
81
+
82
+ def _ui_ask(word, pack):
83
+ """`word` as a UI ask in `pack`'s word order. The gap is bounded and stops
84
+ at a sentence's end, so the qualification is about the phrase the word sits
85
+ in and not about the paragraph it is in."""
86
+ objects = "|".join(pack["ui_objects"]) or "(?!)"
87
+ if pack["word_order"] == "object_first":
88
+ # the object takes its case suffix through \w*
89
+ return r"(?:\b(?:%s)\w*[^.!?\n]{0,40}\b(?:%s))" % (objects, word)
90
+ obj = r"\b(?:%s)\b" % objects
92
91
  return (r"(?:%s[^.!?\n]{0,40}\b(?:%s)\b"
93
92
  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
- )
93
+ % (obj, word, word, obj, "|".join(pack["ui_quality"]) or "(?!)"))
94
+
95
+
96
+ def _hints(key):
97
+ """Every pack's `hints.<key>` fragments as one `\\b(...)\\b` pattern."""
98
+ return r"\b(%s)\b" % "|".join(
99
+ frag if isinstance(frag, str) else _ui_ask(frag["ui_ask"], pack)
100
+ for pack in packs().values() for frag in pack["hints"][key])
101
+
102
+
103
+ def _fanout():
104
+ """The fan-out hint: two or more independent items - a numbered or bulleted
105
+ list of at least two entries on their own lines, or the explicit delegation
106
+ ask. The owner had to say "do everything with subagents and in parallel" on
107
+ every task because nothing armed the fan-out rule from the shape of the
108
+ request. `parallelize` alone is code work, so only the delegation phrasings
109
+ count. The owner's own shapes armed nothing until 2026-10-09: `1 - `, `1- `,
110
+ `1:`, `a)`, `+` items, an inline `1. ... 2. ...`, "subagentlerle", three
111
+ files named as one list, "First, ... Second, ...", and three imperative
112
+ clauses in one sentence (clause-initial in a `verb_first` pack, clause-final
113
+ in an `object_first` one). A timestamp, a range and a traceback stay out
114
+ (the corpus)."""
115
+ # A list item's marker: `1.`, `1)`, `1 -`, `1:`, `a)`, or a `-`/`*`/`+`/`•`
116
+ # bullet, then its text.
117
+ item = r"(?:\d+[ \t]*[.):\u2013-]|[a-z]\)|[-*+\u2022])[ \t]+\S"
118
+ # A file a prompt names: a path with a directory, or a name with an
119
+ # extension. Anchored at a token start: unanchored, a long run of word
120
+ # characters made every position a start and the search quadratic (100 KB
121
+ # took minutes).
122
+ file = (r"(?<![\w./-])[`'\"]?(?:(?:[\w.-]+/)+[\w.-]*\w|"
123
+ r"\w[\w-]*\.[a-z]{1,4}\b)[`'\"]?")
124
+ join = "|".join(w for p in packs().values() for w in p["fan_join"])
125
+ parts = [r"(?ms:^[ \t]*" + item + r".*?^[ \t]*" + item + r")",
126
+ r"(?<![\w.])1[ \t]*[.)-][ \t]+\S[^\n]*?(?<![\w.])2[ \t]*[.)-][ \t]+\S",
127
+ file + r"(?:[ \t]*(?:,[ \t]*(?:(?:%s)[ \t]+)?|(?:%s|&)[ \t]+)" % (join, join)
128
+ + file + r"){2,}"]
129
+ for pack in packs().values():
130
+ first, second = pack["fan_ordinals"]
131
+ parts.append(r"(?s:\b%s[,:].*?\b%s[,:])" % (first, second))
132
+ for pack in packs().values():
133
+ verbs, joins = "|".join(pack["fan_verbs"]), "|".join(pack["fan_join"])
134
+ if pack["word_order"] == "object_first":
135
+ parts.append(r"(?m:(?:\b(?:%s)\w{0,4}[ \t]*(?:[,;.]|\b(?:%s)\b|$)"
136
+ r"[^\n]*?){3})" % (verbs, joins))
137
+ else:
138
+ parts.append(r"(?m:(?:(?:^|[,;]|\b(?:%s)\b)[ \t]*(?:(?:%s)[ \t]+)?(?:%s)"
139
+ r"\b[^\n]*?){3})" % (joins, joins, verbs))
140
+ parts.append(r"\b(?:%s)\b" % "|".join(
141
+ d for p in packs().values() for d in p["fan_directives"]))
142
+ return "|".join(parts)
143
+
144
+
145
+ PROMPT_HINTS = tuple((key, _hints(key)) for key in
146
+ ("spec", "consult", "research", "product", "graph")) + (
147
+ ("fanout", _fanout()),)
377
148
 
378
149
  # the detached auto-index worker (lock-guarded, retrying); same dir as this file
379
150
  INDEX_WORKER = os.path.join(os.path.dirname(os.path.abspath(__file__)),
@@ -390,7 +161,7 @@ CORE_RULES = (
390
161
  ("ponytail", "**Ponytail (minimal code).**"),
391
162
  ("adhd", "**Output shape: ADHD-friendly.**"),
392
163
  ("fidelity", "**Deliver the whole ask; never the shortcut.**"),
393
- ("integrity", '**Integrity: evidence, or "doğrulanmadı".**'),
164
+ ("integrity", '**Integrity: evidence, or a named blocker.**'),
394
165
  ("loop", "**Loop discipline.**"),
395
166
  ("scope", "**Session scope: the user's repo, not tezgah.**"),
396
167
  ("spec", "**Spec before building.**"),
@@ -417,11 +188,12 @@ CORE_RULES = (
417
188
  REMINDER_CLAUSES = (
418
189
  ("exec", "{REPLY_SHORT}, BLUF, "),
419
190
  ("adhd", "answer first - no recap, no closer, at most five ranked items; "),
420
- ("ponytail", "code minimal per ponytail (code first, <=3 note lines); "),
191
+ ("ponytail", "code minimal per ponytail (code first; a note only for a "
192
+ "requested part you cut); "),
421
193
  ("lessons", ".tezgah/lessons.md lines are standing constraints; "),
422
194
  ("consult", "consult before irreversible calls; "),
423
195
  ("integrity", re.compile(r'done/tested claims need observed evidence -> .*?'
424
- r'unverified "done"; ')),
196
+ r'unverified "done"; .*?under "\{BLOCKERS_HEADING\}"; ')),
425
197
  )
426
198
 
427
199
 
@@ -448,12 +220,17 @@ def prompt_reminder(drop=()):
448
220
 
449
221
 
450
222
  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."""
223
+ """Fill the path placeholders with stable, existing paths, and the language
224
+ placeholders - the reply language and the contract's own tokens, `{UNVERIFIED}`,
225
+ `{BLOCKERS_HEADING}`, `{DECISION}`... - with the configured language's words
226
+ (`tezgah_paths.say`; REPLY_ANY's reply clause under `any`)."""
453
227
  if not text:
454
228
  return text
455
- for key, words in REPLY_LANG_TEXT[reply_lang()].items():
456
- text = text.replace(key, words)
229
+ words = say()
230
+ if language() == "any":
231
+ words.update(REPLY_ANY)
232
+ for key, word in words.items():
233
+ text = text.replace("{%s}" % key.upper(), word)
457
234
  return (text.replace("{CONSULT_BIN}", tool("consult"))
458
235
  .replace("{CODEGEN_BIN}", tool("codegen"))
459
236
  .replace("{ORX_BIN}", orx_bin() or "orx")
@@ -787,16 +564,16 @@ def task_line(task):
787
564
  and refuses a write the phase or the allowlist excludes, but a refusal
788
565
  costs a turn - so the phase rides every user turn, and the refusal is never
789
566
  the first the session hears of it. One line because it is paid every turn;
790
- the allowlist is the globs as written, and the phase is stated as the user's
791
- to move rather than as a command to run: the line used to print the CLI the
792
- user types, and E7b watched the session run that command five times in a row
793
- against a gate that refuses it every time. A line that names an act the gate
794
- refuses is an invitation to a loop."""
567
+ the allowlist is the globs as written, and the phase is stated as the
568
+ orchestrator's own move: the session runs `tezgah-task` itself when the
569
+ work reaches the next phase and closes the plan when it is done, never
570
+ handing those steps to the user."""
795
571
  paths = ", ".join(task.get("allowed_paths") or []) or "any path in the repo"
796
- return ("Active task %s is in phase `%s`; writes allowed on: %s. The phase "
797
- "belongs to the user - ask them for the one this work needs, and "
798
- "do not move it yourself." % (task.get("id"), task.get("phase"),
799
- paths))
572
+ return ("Active task %s is in phase `%s`; writes allowed on: %s. Move the "
573
+ "phase yourself with `tezgah-task phase <next>` when the work reaches "
574
+ "it, and `tezgah-task close %s done` when it is merged - never hand "
575
+ "these steps to the user." % (task.get("id"), task.get("phase"),
576
+ paths, task.get("id")))
800
577
 
801
578
 
802
579
  # --- the resume block: the live turn state a compact loses -------------------
@@ -822,6 +599,13 @@ RESUME_PLAN_CHARS = 40
822
599
  RESUME_COMMIT_CHARS = 38
823
600
  RESUME_CHECK_CHARS = 40
824
601
  RESUME_FILES = 3
602
+ # The slice journal's lines (plan 081): the newest three stopped or open slices
603
+ # and background jobs, each already bounded by the journal and cut again here.
604
+ # They ride the resume block and, because an omp `/clear` fires no
605
+ # session_start, every prompt's `slices` part while any is pending.
606
+ SLICE_LINES = 3
607
+ SLICE_CHARS = 160
608
+ SLICES_HEAD = "Slices left stopped or open (the journal, `tezgah-task slice reconcile`):"
825
609
 
826
610
 
827
611
  def _plan_facts(path):
@@ -876,13 +660,33 @@ def _active_plan(root):
876
660
  return paths[0], False
877
661
 
878
662
 
663
+ def slice_lines(root, session_id=None, others=False):
664
+ """This session's pending slices and jobs (`tezgah_task.slice_lines`): at
665
+ most SLICE_LINES lines of at most SLICE_CHARS each, newest first, or []
666
+ with no session, under `slices-off`, without the journal, or when it
667
+ raises - a broken journal costs the lines, never the injection. Another
668
+ session's slices are never listed: they are that session's to resume (omp's
669
+ `--resume` keeps the session id). With `others` - the resume block, read at
670
+ session start and after a compaction - one line counts them, so a session
671
+ that came back under a new id still learns they exist."""
672
+ reader = getattr(tezgah_task, "slice_lines", None)
673
+ if not reader or not session_id or off("slices-off"):
674
+ return []
675
+ try:
676
+ lines = reader(root, limit=SLICE_LINES, session=session_id, others=others) or []
677
+ except Exception:
678
+ return []
679
+ return [cut(str(ln), SLICE_CHARS) for ln in lines[:SLICE_LINES + 1]]
680
+
681
+
879
682
  def resume_state(root, session_id):
880
683
  """The live turn state as a context block, or "" when every part is empty.
881
684
 
882
- The four parts, in order: the active plan's State and Next, the branch's last
685
+ The five parts, in order: the active plan's State and Next, the branch's last
883
686
  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."""
687
+ the files this turn's writes changed, and the journal's stopped or open
688
+ slices (`slice_lines`). Read by session_start and the post-compaction path;
689
+ see the note above for why both."""
886
690
  lines = []
887
691
  plan, mine = _active_plan(root)
888
692
  if plan:
@@ -909,7 +713,7 @@ def resume_state(root, session_id):
909
713
  # lessons, pointer - for a session whose ledger holds one bad line. So a
910
714
  # damaged ledger costs these two bullets and nothing else.
911
715
  try:
912
- row = last_check(session_id) if session_id else None
716
+ row = last_check(session_id, root) if session_id else None
913
717
  files = sorted(changed_files(session_id)) if session_id else []
914
718
  except ValueError:
915
719
  row, files = None, []
@@ -919,6 +723,7 @@ def resume_state(root, session_id):
919
723
  RESUME_CHECK_CHARS)))
920
724
  if files:
921
725
  lines.append("- changed this turn: %s" % ", ".join(files[:RESUME_FILES]))
726
+ lines += slice_lines(root, session_id, others=True)
922
727
  if not lines:
923
728
  return ""
924
729
  return "## Session so far\n" + "\n".join(lines)
@@ -1186,10 +991,34 @@ def constraint_notice(cwd, session_id):
1186
991
  return " ".join(render(subagent_core(core_for(cwd)[0])).split())
1187
992
 
1188
993
 
994
+ # A data ask said by its output: a dashboard, a chart, a KPI, a metric to choose.
995
+ # "Bu veriye uygun bir metric dashboard tasarla" armed nothing before plan 072.
996
+ # These words are code words as often as product words (`dashboard.json`, a
997
+ # metrics endpoint, a progress `gösterge`, the clipboard `pano`), and two rounds
998
+ # of noun denylists inside the product regex kept missing the next code sense.
999
+ # So the ask is judged per clause: it arms `product` only when no code marker -
1000
+ # a file name, an identifier, SQL, a build, test or runtime word - shares its
1001
+ # clause (decision 006: qualify the stem, never drop it).
1002
+ DATA_ASK = re.compile(r"\b(?:%s)" % alts("data_ask"))
1003
+ CODE_SENSE = re.compile(alts("code_sense"))
1004
+
1005
+
1006
+ def _data_ask(low):
1007
+ """Does one clause of the prompt ask for a data view or a metric choice with
1008
+ no code marker beside it? A clause ends at `!`, `?`, `;`, a newline or a full
1009
+ stop followed by a space, so `dashboard.json` stays one clause."""
1010
+ return any(DATA_ASK.search(clause) and not CODE_SENSE.search(clause)
1011
+ for clause in re.split(r"[!?;\n]|\.\s", low))
1012
+
1013
+
1189
1014
  def classify_prompt(text):
1190
- """The conditional rule keys a prompt arms, from the shared hint table."""
1015
+ """The conditional rule keys a prompt arms, from the shared hint table, plus
1016
+ `product` for a data ask (DATA_ASK, gated per clause by CODE_SENSE)."""
1191
1017
  low = (text or "").lower()
1192
- return {key for key, pattern in PROMPT_HINTS if re.search(pattern, low)}
1018
+ keys = {key for key, pattern in PROMPT_HINTS if re.search(pattern, low)}
1019
+ if "product" not in keys and _data_ask(low):
1020
+ keys.add("product")
1021
+ return keys
1193
1022
 
1194
1023
 
1195
1024
  def prompt_text(payload):
@@ -1407,11 +1236,22 @@ def session_of(payload):
1407
1236
  # ADHD rules inline from the first turn (+1042 B), the fan-out pointer (+112 B)
1408
1237
  # and the `tezgah-skill` line (+113 B) took the healthy fixture's session_start
1409
1238
  # from 10579 B to 12059 B, so at 12000 B a healthy repo gave up its lessons
1410
- # and sibling checkouts to pay for core rules; 13000 B restores the headroom. A
1239
+ # and sibling checkouts to pay for core rules; 13000 B restores the headroom.
1240
+ # Moved again on 2026-10-11 (plan 080): the integrity paragraph carries the
1241
+ # residual rule (the four blockers and the "Engeller" format) and the reminder
1242
+ # its one-line form, +1473 B of core and +151 B of reminder; at 13000 B the
1243
+ # healthy fixture (13588 B) gave up its lessons, plans and steer blocks (950 B;
1244
+ # 1002 B where a subagents note rides along), and at 6000 B the busiest prompt
1245
+ # turn its active-task line (255 B), so
1246
+ # the session budgets move to 14500 B and the prompt budget to 6500 B.
1247
+ # Moved again on 2026-10-12 (plan 086, S3): the integrity paragraph carries
1248
+ # the Ask policy (+913 B of core), and at 14500 B the healthy fixture gave up
1249
+ # its lessons, plans or knowledge block (235-338 B), so the session budgets
1250
+ # move to 15500 B. A
1411
1251
  # budget in bytes, not tokens: this file has no tokenizer and a wrong estimate
1412
1252
  # would be worse than a bound.
1413
- CONTEXT_BUDGET = {"session_start": 13000, "post_compact": 13000,
1414
- "subagent_start": 5500, "user_prompt": 6000}
1253
+ CONTEXT_BUDGET = {"session_start": 15500, "post_compact": 15500,
1254
+ "subagent_start": 5500, "user_prompt": 6500}
1415
1255
  DEFAULT_BUDGET = 13000
1416
1256
  # The blocks in the order they are given up when the budget is exceeded, lowest
1417
1257
  # value first: text another surface already carries (the plan table lives in the
@@ -1426,8 +1266,10 @@ DEFAULT_BUDGET = 13000
1426
1266
  # block, which outlives every status and availability line because it is the only
1427
1267
  # one that says what THIS session was doing (a `git log` and a ledger read are
1428
1268
  # turns the session would otherwise spend) but still yields to a rule - then the
1429
- # scratch-path warning, which is about evidence the turn may already have claimed
1430
- # - then the active task's phase, which outlives both because a phase is what
1269
+ # journal's stopped or open slices, which outlive the resume block because after
1270
+ # an omp `/clear` they are the one pointer left to delegated work in flight - then
1271
+ # the scratch-path warning, which is about evidence the turn may already have
1272
+ # claimed - then the active task's phase, which outlives both because a phase is what
1431
1273
  # stops a refused write before it happens - then the delta, and the skill pointer
1432
1274
  # last. A key absent from this tuple is never dropped: the always-on core, the
1433
1275
  # per-turn reminder and an armed paragraph ARE the rules, and a budget that could
@@ -1435,8 +1277,9 @@ DEFAULT_BUDGET = 13000
1435
1277
  # exists to prevent, not one it may cause.
1436
1278
  DROP_ORDER = ("knowledge", "worktrees", "lessons", "skill", "lessons_turn",
1437
1279
  "taste", "plans", "subagents", "steer", "orca", "consult", "research",
1438
- "research_broken", "graph", "offnote", "orchestrate", "index",
1439
- "resume", "scratch", "task", "delta", "pointer")
1280
+ "research_broken", "graph", "offnote", "orchestrate",
1281
+ "index", "resume", "slices", "context", "scratch", "task", "delta",
1282
+ "pointer")
1440
1283
 
1441
1284
 
1442
1285
  def _first_item(text):
@@ -1558,7 +1401,8 @@ SCRATCH_CHARS = 120
1558
1401
  # while the tool hooks never run - an unloaded plugin, a matcher the host
1559
1402
  # renamed, a hook path that moved. The gate is then off and nothing says so.
1560
1403
  # What the prompt hook can observe is the host's own transcript (Claude and
1561
- # Codex hand `transcript_path` to every hook) and this session's ledger: gated
1404
+ # Codex hand `transcript_path` to every hook, omp's bridge hands its session
1405
+ # file to the prompt hook) and this session's ledger: gated
1562
1406
  # tool calls in the transcript since the session's first turn marker, and not
1563
1407
  # one row from the gate over the same span, is a gate that is not running. Only
1564
1408
  # a bounded tail of the transcript is read, the ledger is scanned as bytes, and
@@ -1567,7 +1411,8 @@ GATE_TAIL = 262144
1567
1411
  GATE_MIN_CALLS = 3
1568
1412
  # The tool names whose every call leaves a PreToolUse row (`began`,
1569
1413
  # tezgah_gate.decision's write and shell branch) and every refused one a `deny`:
1570
- # Claude's write and shell tools and Codex's `exec_command`/`apply_patch`. A
1414
+ # Claude's write and shell tools, omp's `bash`/`edit`/`write` and Codex's
1415
+ # `exec_command`/`apply_patch`. A
1571
1416
  # tool the gate may pass without a row (Read, Grep, Task, WebFetch, an MCP
1572
1417
  # call) is not counted, so its absence from the ledger is never read as a
1573
1418
  # disarmed gate.
@@ -1577,7 +1422,7 @@ GATE_INACTIVE = (
1577
1422
  "tezgah gate inactive on this host: %d gated tool call(s) since this "
1578
1423
  "session's first turn and not one row from the gate, so the shortcut "
1579
1424
  "denials and the Stop check are not running. Say so before claiming a check "
1580
- "passed, and ask the user to run `tezgah-setup --report`.")
1425
+ "passed, run `tezgah-setup --report` yourself and quote its MISS rows.")
1581
1426
 
1582
1427
 
1583
1428
  def _iso_epoch(value):
@@ -1588,25 +1433,32 @@ def _iso_epoch(value):
1588
1433
  return None
1589
1434
 
1590
1435
 
1591
- def _transcript_calls(path, since):
1592
- """Gated tool calls in the transcript's last GATE_TAIL bytes stamped at or
1593
- after `since` (epoch seconds). Claude writes `{"timestamp", "message":
1594
- {"content": [{"type": "tool_use", "name"}]}}`, Codex `{"timestamp",
1595
- "payload": {"type": "function_call", "name"}}`; anything else counts 0."""
1436
+ def _tail_lines(path):
1437
+ """The raw lines of a `.jsonl` transcript's last GATE_TAIL bytes, the cut
1438
+ first line dropped. [] for any other path or an unreadable file, so a host
1439
+ that hands no transcript pays nothing."""
1596
1440
  if not isinstance(path, str) or not path.endswith(".jsonl"):
1597
- return 0
1441
+ return []
1598
1442
  try:
1599
1443
  with open(path, "rb") as fh:
1600
1444
  size = fh.seek(0, 2)
1601
1445
  fh.seek(max(0, size - GATE_TAIL))
1602
1446
  lines = fh.read().split(b"\n")
1603
1447
  except OSError:
1604
- return 0
1605
- if size > GATE_TAIL:
1606
- lines = lines[1:] # the first line is cut, not a row
1448
+ return []
1449
+ return lines[1:] if size > GATE_TAIL else lines
1450
+
1451
+
1452
+ def _transcript_calls(path, since):
1453
+ """Gated tool calls in the transcript's last GATE_TAIL bytes stamped at or
1454
+ after `since` (epoch seconds). Claude writes `{"timestamp", "message":
1455
+ {"content": [{"type": "tool_use", "name"}]}}`, omp the same with
1456
+ `"toolCall"` blocks, Codex `{"timestamp", "payload": {"type":
1457
+ "function_call", "name"}}`. Anything else counts 0."""
1607
1458
  calls = 0
1608
- for raw in lines:
1609
- if b"tool_use" not in raw and b"function_call" not in raw:
1459
+ for raw in _tail_lines(path):
1460
+ if (b"tool_use" not in raw and b"toolCall" not in raw
1461
+ and b"function_call" not in raw):
1610
1462
  continue
1611
1463
  try:
1612
1464
  row = json.loads(raw)
@@ -1619,7 +1471,7 @@ def _transcript_calls(path, since):
1619
1471
  msg = row.get("message")
1620
1472
  if isinstance(msg, dict) and isinstance(msg.get("content"), list):
1621
1473
  names = [b.get("name") for b in msg["content"]
1622
- if isinstance(b, dict) and b.get("type") == "tool_use"]
1474
+ if isinstance(b, dict) and b.get("type") in ("tool_use", "toolCall")]
1623
1475
  pay = row.get("payload")
1624
1476
  if isinstance(pay, dict) and pay.get("type") == "function_call":
1625
1477
  names.append(pay.get("name"))
@@ -1627,10 +1479,72 @@ def _transcript_calls(path, since):
1627
1479
  return calls
1628
1480
 
1629
1481
 
1482
+ # The usage keys that make up one step's input side: Claude's names, then omp's.
1483
+ # Codex reports the cached share inside `input_tokens`.
1484
+ INPUT_KEYS = ("input_tokens", "cache_creation_input_tokens",
1485
+ "cache_read_input_tokens", "input", "cacheRead", "cacheWrite")
1486
+ CONTEXT_NUDGE = (
1487
+ "Router context %dk tokens: push heavy reads and check output through a "
1488
+ "slice that returns the exit code and the failing lines. Hand the open "
1489
+ "work to a slice or run `/compact` before the next heavy step.")
1490
+ # The row a compaction leaves: omp's `{"type": "compaction"}`, Claude's
1491
+ # `compact_boundary` system row. A step before it measured the old context.
1492
+ COMPACTED = re.compile(rb'"(?:type"\s*:\s*"compaction|subtype"\s*:\s*"compact_boundary)"')
1493
+
1494
+
1495
+ def context_tokens(path):
1496
+ """Input-side tokens of the newest main-thread assistant step in the
1497
+ transcript tail, or None. Claude and omp put `usage` on the assistant
1498
+ message (a Claude `isSidechain` row is a subagent's), Codex on a
1499
+ `token_count` event's `last_token_usage`. A compaction row met first means
1500
+ no step since the compaction, so None."""
1501
+ for raw in reversed(_tail_lines(path)):
1502
+ if COMPACTED.search(raw):
1503
+ return None
1504
+ if b"usage" not in raw:
1505
+ continue
1506
+ try:
1507
+ row = json.loads(raw)
1508
+ except ValueError:
1509
+ continue
1510
+ if not isinstance(row, dict) or row.get("isSidechain"):
1511
+ continue
1512
+ msg, pay = row.get("message"), row.get("payload")
1513
+ usage = None
1514
+ if isinstance(msg, dict) and msg.get("role") == "assistant":
1515
+ usage = msg.get("usage")
1516
+ elif isinstance(pay, dict) and pay.get("type") == "token_count":
1517
+ usage = (pay.get("info") or {}).get("last_token_usage")
1518
+ if isinstance(usage, dict):
1519
+ n = sum(v for k, v in usage.items() if k in INPUT_KEYS and isinstance(v, int))
1520
+ if n:
1521
+ return n
1522
+ return None
1523
+
1524
+
1525
+ def context_key(session_id):
1526
+ """The router-context line's key in the `nudged` mark store."""
1527
+ return "context:" + slug(str(session_id))
1528
+
1529
+
1530
+ def context_nudge(payload, session_id):
1531
+ """The one line asking the router to delegate heavy reading, or "" below
1532
+ CONTEXT_NUDGE_TOKENS, without a transcript, under `orchestrate-off`, or
1533
+ once `context_for` latched it for this session (cleared by a compaction)."""
1534
+ path = payload.get("transcript_path") if isinstance(payload, dict) else None
1535
+ if not path or off("orchestrate-off") or tezgah_store.marked(
1536
+ "nudged", context_key(session_id)):
1537
+ return ""
1538
+ tokens = context_tokens(path)
1539
+ return CONTEXT_NUDGE % (tokens // 1000) if (
1540
+ tokens or 0) >= CONTEXT_NUDGE_TOKENS else ""
1541
+
1542
+
1630
1543
  # The ledger kinds a hook other than the tool hooks writes: the prompt hook's
1631
1544
  # turn marker, judge row, lesson rows and disarm row, the Stop hook's claim,
1632
1545
  # refusal, after_block, shape and stop_spec rows, the subagent-end hook's subagent_end row,
1633
- # compaction, the subagent mark (SubagentStart writes it too), the guard's crash
1546
+ # compaction, the subagent mark (SubagentStart writes it too), the omp fork's
1547
+ # `forked` row, the guard's crash
1634
1548
  # row (any hook), the damage row any reader writes and the session start's
1635
1549
  # attest row. Every other kind - deny, nudge, began, snapshot, ... - can only
1636
1550
  # come from a tool hook.
@@ -1640,7 +1554,7 @@ def _transcript_calls(path, since):
1640
1554
  NOT_TOOL_HOOK = frozenset((b"turn", b"judge", b"claim", b"refusal",
1641
1555
  b"after_block", b"subagent_end", b"stop_spec",
1642
1556
  b"shape", b"compact", b"orch", b"crash", b"route",
1643
- b"spawned", b"lesson", b"disarm", b"ledger_damage",
1557
+ b"spawned", b"forked", b"lesson", b"disarm", b"ledger_damage",
1644
1558
  b"attest"))
1645
1559
  # The rows PostToolUse writes (`note_tool`): they prove the host ran the call,
1646
1560
  # not that the gate saw it - a PostToolUse hook keeps writing them while a
@@ -1777,15 +1691,31 @@ POINTER_NEEDLE = "tezgah-contract"
1777
1691
  # constraint that the user never meant is a rule the session did not get from
1778
1692
  # the user. tests/constraint-fixtures.md holds the real prompts it was measured
1779
1693
  # on, its false positives listed.
1780
- CONSTRAINT_SHAPES = re.compile(
1781
- r"(?i)\b(?:(?:do not|don'?t|never)\s+(?:touch|modify|edit|change|delete|"
1782
- r"remove|rename|push|commit|merge|deploy|publish|release)\s+"
1783
- r"(?:(?:the|any|my|this|that|to|a|an)\s+)?[`\"“]?"
1784
- r"(?P<obj>[\w~@/-](?:[\w./@~-]*[\w@~/-])?)"
1785
- r"|ask\s+(?:me\s+)?(?:first\s+)?before\s+(?P<ask>\w+)"
1786
- r"|(?P<tr>[\w./@~-]+?)(?:'\w+)?\s+(?:sakın\s+)?dokunma(?:yın|yınız)?\b"
1787
- r"(?!\s+(?:hedef|alan|olay|duyar)\w*)"
1788
- r"|(?:bana\s+)?sormadan\s+(?P<trask>\w+)\s+(?:etme|yapma)\w*)")
1694
+ def _constraint_shapes():
1695
+ """Every pack's `constraint.dont` and `constraint.ask` shapes in one
1696
+ pattern, each named group suffixed with its pack's code (`obj_en`,
1697
+ `ask_tr`), so `_shape` can tell which language's shape matched."""
1698
+ return re.compile(r"(?i)\b(?:%s)" % "|".join(
1699
+ frag.replace("(?P<obj>", "(?P<obj_%s>" % code).replace(
1700
+ "(?P<ask>", "(?P<ask_%s>" % code)
1701
+ for code, pack in packs().items() for kind in ("dont", "ask")
1702
+ for frag in pack["constraint"][kind]))
1703
+
1704
+
1705
+ CONSTRAINT_SHAPES = _constraint_shapes()
1706
+
1707
+
1708
+ def _shape(m):
1709
+ """(kind, group, pack) of a CONSTRAINT_SHAPES match: `obj` for a
1710
+ prohibition's object, `ask` for an ask-first clause's verb. Read off the
1711
+ one named group that matched (`groupdict`), so a pack fragment's own
1712
+ groups cannot stand in for it (tests/test_lang_packs.py holds each
1713
+ fragment to its one named group)."""
1714
+ group = next(k for k, v in m.groupdict().items() if v is not None)
1715
+ kind, code = group.split("_", 1)
1716
+ return kind, group, packs()[code]
1717
+
1718
+
1789
1719
  # Pasted material is not the user's constraint: a shape that starts inside a
1790
1720
  # quoted span, inline code, a fenced block or a `>` quote line is skipped (a
1791
1721
  # quoted object after an unquoted shape stays the user's), and a prompt
@@ -1795,11 +1725,12 @@ CONSTRAINT_SHAPES = re.compile(
1795
1725
  # log alone matched six clauses of another agent's reasoning. ponytail: a long
1796
1726
  # prompt the user did write loses its constraints - the cost of a length rule.
1797
1727
  CONSTRAINT_PROMPT_MAX = 12000
1728
+ # Single quotes count only off a word ("main'e" is no quote), with ‘…’ and «…».
1798
1729
  QUOTED = re.compile(r"```.*?```|`[^`\n]*`|\"[^\"\n]{0,200}\"|“[^”\n]{0,200}”"
1730
+ r"|(?<!\w)'[^'\n]{1,200}'(?!\w)|‘[^’\n]{0,200}’|«[^»\n]{0,200}»"
1799
1731
  r"|^\s*>.*$", re.S | re.M)
1800
1732
  SENTENCE_END = re.compile(r"[.!?;:](?=\s|$)|\n")
1801
- NEEDLE_STOP = frozenset("while in on at without until unless and or for to with "
1802
- "before after from into of but ve veya ile".split())
1733
+ NEEDLE_STOP = frozenset(w for p in packs().values() for w in p["needle_stop"])
1803
1734
  QUOTE_MARKS = re.compile(r"[`\"“”]")
1804
1735
  NEEDLE_WORDS = 3
1805
1736
  CONSTRAINT_CHARS = 120
@@ -1809,8 +1740,8 @@ CONSTRAINTS_MAX = 5
1809
1740
  def user_constraints(prompt):
1810
1741
  """The constraint clauses a prompt issues, redacted and cut, in order and
1811
1742
  once each. A clause runs from the shape to the end of its sentence - from the
1812
- sentence's start for the Turkish forms, whose object comes first. The stamp
1813
- keeps the clause, never the prompt."""
1743
+ sentence's start for an `object_first` pack's prohibition (Turkish), whose
1744
+ object comes first. The stamp keeps the clause, never the prompt."""
1814
1745
  text = prompt or ""
1815
1746
  if len(text) > CONSTRAINT_PROMPT_MAX:
1816
1747
  return []
@@ -1819,8 +1750,11 @@ def user_constraints(prompt):
1819
1750
  for m in CONSTRAINT_SHAPES.finditer(text):
1820
1751
  if any(a <= m.start() < b for a, b in quoted):
1821
1752
  continue
1753
+ kind, _group, pack = _shape(m)
1822
1754
  start = (max((e.end() for e in SENTENCE_END.finditer(text, 0, m.start())),
1823
- default=0) if m.group("tr") else m.start())
1755
+ default=0)
1756
+ if kind == "obj" and pack["word_order"] == "object_first"
1757
+ else m.start())
1824
1758
  end = SENTENCE_END.search(text, m.end())
1825
1759
  clause = " ".join(text[start:end.start() if end else len(text)].split())
1826
1760
  clause = cut(redact(clause.strip("-*• ")), CONSTRAINT_CHARS)
@@ -1828,6 +1762,97 @@ def user_constraints(prompt):
1828
1762
  out.append(clause)
1829
1763
  return out
1830
1764
 
1765
+ # A grant is the positive half of the same reader (plan 086, S3): "bana sormadan
1766
+ # pushla", "without asking", "onay veriyorum" lets the session take an outward
1767
+ # step without asking again; "artık bana sor", "ask me again" or a later
1768
+ # prohibition that names the step ("bana sormadan push etme", "don't push")
1769
+ # lifts it. Each is one `grant` row in the session's ledger, scoped to the
1770
+ # OUTWARD class the sentence names, `all` when it names none; the Stop rule's
1771
+ # `needless ask` and the ask-tool warning read them back
1772
+ # (`tezgah_integrity.grants_in_force`). Never a class-F step: a grant lowers
1773
+ # asks, it never licenses a force push, a delete or a spend over the cap.
1774
+ LIFT_SHAPES = re.compile(r"(?i)\b(?:%s)" % alts("constraint", "lift"))
1775
+ # A negated outward step ("Don't push.", "şimdilik push yapma") lifts only the
1776
+ # class its own words name, never `all`.
1777
+ LIFT_OUTWARD = re.compile(r"(?i)\b(?:%s)" % alts("constraint", "lift_outward"))
1778
+ # A grant shape in a sentence that forbids, complains or asks is no grant:
1779
+ # "bana sormadan hiçbir şey yapma", "neden bana sormadan pushladın?", "Never
1780
+ # tag without asking me". Each pack's rules read only the sentences its own
1781
+ # grant shape matched, so "theme" is no Turkish -me: `grant_not` anywhere in
1782
+ # the sentence with the grant shape blanked ("don't ask me again" stays a
1783
+ # grant), the pack's question particle, and `grant_not_verb` only on the word
1784
+ # right after the grant shape or the sentence's last word - one of
1785
+ # `grant_not_soft` ("durma", "unutma") only after an outward word. ponytail: a
1786
+ # Turkish noun ending in -ma/-me in those two places also reads as a
1787
+ # prohibition - a missed grant costs one question, a false one an unasked push.
1788
+
1789
+
1790
+ def _any(frags):
1791
+ return re.compile(r"(?i)(?:%s)" % ("|".join(frags) or "(?!)"))
1792
+
1793
+
1794
+ GRANT_PACKS = [(re.compile(r"(?i)\b(?:%s)" % "|".join(p["constraint"]["grant"])),
1795
+ _any(p["constraint"]["grant_not"]
1796
+ + [r"\b(?:%s)\b" % q for q in p["question_end"]]),
1797
+ _any(p["constraint"]["grant_not_verb"]),
1798
+ _any(p["constraint"]["grant_not_soft"]))
1799
+ for p in packs().values() if p["constraint"]["grant"]]
1800
+ WORD = re.compile(r"[^\W\d_]+")
1801
+
1802
+
1803
+ def _refuses(text, start, at, to, stop, nots, verb, soft):
1804
+ """True when the sentence `text[start:stop]` takes back the grant shape
1805
+ at `text[at:to]` (GRANT_PACKS' rules of the pack that matched it)."""
1806
+ if text[stop:stop + 1] == "?" or nots.search(text[start:at] + " " + text[to:stop]):
1807
+ return True
1808
+ words = [m for m in WORD.finditer(text, start, stop) if not at <= m.start() < to]
1809
+ near = [m for m in words if m.start() >= to][:1] + words[-1:]
1810
+ return any(verb.fullmatch(m.group(0)) and not (
1811
+ soft.fullmatch(m.group(0)) and not outward_classes(text[start:m.start()]))
1812
+ for m in near)
1813
+
1814
+
1815
+ def user_grants(prompt):
1816
+ """[(clause, target)] for every grant and lift a prompt issues, in order:
1817
+ `target` is an OUTWARD class or `all`, `lift:` before it for a lift. A
1818
+ sentence is read whole for the class it names, since a Turkish grant often
1819
+ names the step before "sormadan"; a grant its sentence takes back
1820
+ (`_refuses`) is none, a lift shape inside a grant shape ("don't ask me
1821
+ again") is the grant's, and a LIFT_OUTWARD match lifts only the classes it
1822
+ names. Pasted material and an over-long prompt are skipped as
1823
+ `user_constraints` skips them."""
1824
+ text = prompt or ""
1825
+ if len(text) > CONSTRAINT_PROMPT_MAX:
1826
+ return []
1827
+ quoted = [q.span() for q in QUOTED.finditer(text)]
1828
+ found = [(m.start(), m.end(), rules) for shapes, *rules in GRANT_PACKS
1829
+ for m in shapes.finditer(text)]
1830
+ grants = [(a, b) for a, b, _r in found]
1831
+ found += [(m.start(), m.end(), kind) for kind, shapes in
1832
+ (("lift", LIFT_SHAPES), ("outward", LIFT_OUTWARD))
1833
+ for m in shapes.finditer(text)
1834
+ if not any(a <= m.start() < b for a, b in grants)]
1835
+ out = []
1836
+ for at, to, rules in sorted(found, key=lambda f: f[:2]):
1837
+ if any(a <= at < b for a, b in quoted):
1838
+ continue
1839
+ lift = isinstance(rules, str)
1840
+ start = max((e.end() for e in SENTENCE_END.finditer(text, 0, at)), default=0)
1841
+ end = SENTENCE_END.search(text, to)
1842
+ stop = end.start() if end else len(text)
1843
+ if not lift and _refuses(text, start, at, to, stop, *rules):
1844
+ continue
1845
+ sentence = " ".join(text[start:stop].split())
1846
+ clause = cut(redact(sentence.strip("-*• ")), CONSTRAINT_CHARS)
1847
+ classes = (sorted(outward_classes(text[at:to])) if rules == "outward"
1848
+ else sorted(outward_classes(sentence)) or ["all"])
1849
+ for cls in classes:
1850
+ out.append((clause, ("lift:" if lift else "") + cls))
1851
+ for clause in user_constraints(text):
1852
+ out += [(clause, "lift:" + cls) for cls in sorted(outward_classes(clause))]
1853
+ return list(dict.fromkeys(out))
1854
+
1855
+
1831
1856
 
1832
1857
  def constraint_needle(clause):
1833
1858
  """The fragment of a constraint a summary keeps when it kept the constraint:
@@ -1849,13 +1874,14 @@ def constraint_needle(clause):
1849
1874
  if len(kept) == NEEDLE_WORDS or bare != word:
1850
1875
  break
1851
1876
  return kept
1852
- if m.group("obj"):
1853
- needle = " ".join(phrase(clause[m.start("obj"):].split()))
1854
- elif m.group("tr"):
1855
- before = phrase(reversed(clause[:m.end("tr")].split()))
1877
+ kind, group, pack = _shape(m)
1878
+ if kind == "ask":
1879
+ needle = m.group(group)
1880
+ elif pack["word_order"] == "object_first":
1881
+ before = phrase(reversed(clause[:m.end(group)].split()))
1856
1882
  needle = " ".join(reversed(before)).split("'")[0]
1857
1883
  else:
1858
- needle = m.group("ask") or m.group("trask")
1884
+ needle = " ".join(phrase(clause[m.start(group):].split()))
1859
1885
  return needle or clause
1860
1886
 
1861
1887
 
@@ -1942,6 +1968,8 @@ def context_for(event, cwd, payload=None, with_core=True):
1942
1968
  if event == "post_compact" or (event == "session_start" and isinstance(
1943
1969
  payload, dict) and payload.get("source") == "compact"):
1944
1970
  forget_seen(session_of(payload))
1971
+ # the context line may fire again on the compacted context
1972
+ tezgah_store.drop("nudged", context_key(session_of(payload)))
1945
1973
  # The switch latch: every off() below answers for this session, and a
1946
1974
  # prompt's turn marker (with its `authorized` row) is written first, so a
1947
1975
  # switch the user's prompt names is honored from this very turn.
@@ -1972,6 +2000,13 @@ def context_for(event, cwd, payload=None, with_core=True):
1972
2000
  # even when the per-turn text is off. Never in DROP_ORDER, so no budget
1973
2001
  # gives it up - it says the rules below are not being enforced.
1974
2002
  gate = gate_inactive(session_id, payload)
2003
+ # The user's grants and lifts (`user_grants`), before the reminder
2004
+ # switch too: the Stop rule reads them whether or not this turn's text
2005
+ # is shown. A subagent's prompt is its parent's brief, never the
2006
+ # user's grant (omp's bridge sends `subagent`).
2007
+ for clause, target in ([] if isinstance(payload, dict) and payload.get("subagent")
2008
+ else user_grants(prompt)):
2009
+ note(session_id, GRANT_KIND, clause, target=target)
1975
2010
  # per-turn nudge: openers decay over long sessions. Kept short because
1976
2011
  # it is paid every turn, and on Claude the output style already carries
1977
2012
  # the same rules on every response. The conditional rules ride along
@@ -2067,6 +2102,26 @@ def context_for(event, cwd, payload=None, with_core=True):
2067
2102
  if scratch:
2068
2103
  command = str(scratch.get("detail") or "").strip()
2069
2104
  parts.append(("scratch", SCRATCH_REMINDER % command[:SCRATCH_CHARS]))
2105
+ # The journal's stopped or open slices and background jobs: an omp
2106
+ # `/clear` fires no session_start, so the resume block never reaches the
2107
+ # next turn and this part is the one that names the work left in flight.
2108
+ pending = slice_lines(root, session_id)
2109
+ if pending:
2110
+ parts.append(("slices", "\n".join([SLICES_HEAD] + pending)))
2111
+ # The router's own context: above the threshold, one line asks it to
2112
+ # push heavy reading through a slice (orchestrator-flow-audit S2 F1).
2113
+ heavy = context_nudge(payload, session_id)
2114
+ if heavy:
2115
+ parts.append(("context", heavy))
2116
+ # a long session outlives a provider's reset: the same due check as
2117
+ # session start (one small JSON read), so a family rejoins omp's chain
2118
+ # without waiting for the next session
2119
+ try:
2120
+ import tezgah_models
2121
+ if tezgah_models.health_due():
2122
+ tezgah_models.start_health()
2123
+ except Exception:
2124
+ pass
2070
2125
  stale = index_notice(cwd)
2071
2126
  if stale:
2072
2127
  parts.append(("index", stale))
@@ -2074,6 +2129,12 @@ def context_for(event, cwd, payload=None, with_core=True):
2074
2129
  parts.append(("offnote", "(off this session: %s)"
2075
2130
  % ", ".join(disabled)))
2076
2131
  text = budgeted(event, parts)
2132
+ # latched only when the budget kept it, so a dropped line comes back
2133
+ if heavy and heavy in text:
2134
+ try:
2135
+ tezgah_store.mark("nudged", context_key(session_id))
2136
+ except tezgah_store.ERRORS:
2137
+ pass # fail open: an unwritable store shows the line again
2077
2138
  items = [ln for ln in relevant.split("\n") if ln.startswith("- ")]
2078
2139
  shown = ([k for k, ln in zip(keys, items) if ln in text] if items
2079
2140
  else keys if relevant and relevant in text else [])
@@ -2138,10 +2199,19 @@ def context_for(event, cwd, payload=None, with_core=True):
2138
2199
  "research to a host subagent and say the tooling is "
2139
2200
  "unavailable; do not improvise its protocol."))
2140
2201
  if event == "session_start":
2141
- note = sync_agents(root)
2142
- if note:
2202
+ synced = sync_agents(root)
2203
+ if synced:
2143
2204
  parts.append(("subagents",
2144
- "Subagents (this repo, generated): %s" % note))
2205
+ "Subagents (this repo, generated): %s" % synced))
2206
+ # the provider health behind omp's subagent chains: re-probed in the
2207
+ # background at most every few hours or once a recorded reset passes, so
2208
+ # a family at its limit leaves the chain and rejoins it after the reset
2209
+ try:
2210
+ import tezgah_models
2211
+ if tezgah_models.health_due():
2212
+ tezgah_models.start_health()
2213
+ except Exception:
2214
+ pass
2145
2215
  # orchestrate-off says "do not delegate", so no line may name delegates
2146
2216
  steer = (None if off("orchestrate-off")
2147
2217
  else steering(root, (payload or {}).get("host")))