@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
@@ -37,8 +37,8 @@ try:
37
37
  except ImportError: # not POSIX: a legacy JSONL ledger was appended unlocked
38
38
  fcntl = None
39
39
 
40
- from tezgah_paths import (SWITCHES, _toplevel, cache_dir, latch, off, reply_lang,
41
- root_for)
40
+ from tezgah_paths import (SWITCHES, _toplevel, alts, cache_dir, language, latch,
41
+ off, pack_values, packs, root_for, say, spend_cap)
42
42
  import tezgah_store as ts
43
43
 
44
44
  # A command that actually checks the change, as opposed to one that merely runs.
@@ -254,8 +254,10 @@ LITERALS = re.compile(
254
254
  r"'(?:\\.|[^'\\\n])*'|\"(?:\\.|[^\"\\\n])*\"|"
255
255
  r"/\*(?:.|\n)*?\*/|//[^\n]*|#[^\n]*")
256
256
  HEREDOC = re.compile(r"<<-?\s*['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?")
257
- # a completion / verification claim, English and Turkish. The forms are the ones
258
- # the machine's own transcripts were measured to use (161 final replies in
257
+ # a completion / verification claim, in every shipped language: the fragments
258
+ # live in hooks/lang/<code>.json (`done`, `verified`), and `alts` joins every
259
+ # pack's into one alternation (S5). The forms are the ones the machine's own
260
+ # transcripts were measured to use (161 final replies in
259
261
  # ~/.omp/agent/sessions/-Projects-tezgah, 2026-09-19): the completed-state
260
262
  # spelling is the common one and the list this replaces missed 13 of 15
261
263
  # hand-drawn completions - "güncellendi", "Push tamam", "kuruldu", "eklendi",
@@ -264,26 +266,26 @@ HEREDOC = re.compile(r"<<-?\s*['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?")
264
266
  # is refused on its evidence whatever the vocabulary says (the trigger below),
265
267
  # so this list is read for the claim row's detail - and therefore for the
266
268
  # false-completion denominator - not for the verdict.
267
- DONE = re.compile(
268
- r"\b(done|complete[d]?|finished|implemented|fixed|shipped|landed|merged|"
269
- r"wired up|wired into|all tests? pass|all checks? pass|"
270
- r"(?:tests?|checks?|suite) (?:is |are )?(?:green|passing)|"
271
- r"build (?:passes|is green)|"
272
- r"yaptım|tamamladım|tamamlandı|bitirdim|bitirildi|ekledim|eklendi|"
273
- r"düzelttim|düzeltildi|hallettim|güncellendi|kuruldu|uygulandı|"
274
- r"yayına girdi|kapatıldı|yüklendi|"
275
- r"tüm testler geçti|testler geçti|testler yeşil|çalışıyor)\b", re.I)
276
- VERIFIED = re.compile(
277
- r"\b(tested|verified|i ran|ran the (?:tests?|suite|build|lint|checks?)|"
278
- r"doğruladım|test ettim|kontrol ettim|denetledim|doğrulandı|test edildi)\b",
279
- re.I)
269
+ DONE = re.compile(r"\b(%s)\b" % alts("done"), re.I)
270
+ VERIFIED = re.compile(r"\b(%s)\b" % alts("verified"), re.I)
280
271
  # The DONE forms that say a check passed ("all tests pass", "testler yeşil"):
281
272
  # such a claim rests on a pass even when the turn only edited prose.
282
273
  TESTED_WORD = re.compile(r"test|check|suite", re.I)
283
- # an explicit admission that removes the lie: an unverified claim is allowed
284
- NEGATED = re.compile(
285
- r"doğrulanmadı|doğrulamadım|unverified|not verified|could ?n[o']t verify|"
286
- r"verification (?:was )?skipped|kontrol edilmedi|test edilmedi", re.I)
274
+ # An explicit admission that the work is unverified. Until plan 080 the word
275
+ # alone cleared every claim branch, so writing it was cheaper than verifying;
276
+ # it now clears only a class whose repair is a check (ADMITTED_CLASSES), and
277
+ # only when a check ran and failed after the turn's newest change
278
+ # (`_failed_since_change`, applied by `_admitted`).
279
+ NEGATED = re.compile(alts("negated"), re.I)
280
+ ADMITTED_CLASSES = frozenset(("check failed", "partial failure", "stale evidence",
281
+ "no verify_ok"))
282
+ # What the evidence classes tell the model the admission takes, in one place.
283
+ # The admission word and the blocker tokens are the contract's own, filled from
284
+ # the configured language's pack when the refusal is written (`_said`).
285
+ ADMISSION = ('"{UNVERIFIED}" ends the turn only after a check that ran and failed '
286
+ 'since the newest change; quote its error line. A failing check of '
287
+ 'your own code is work to fix, not a blocker: list it under '
288
+ '"{BLOCKERS_HEADING}" only when it truly is {ACCESS} or {ENV}, typed so')
287
289
  # Rule 10 of skills/i-have-adhd/SKILL.md - "No preamble, no recap, no closer" -
288
290
  # names the forms a reply may neither open nor close on. Each alternative below
289
291
  # is quoted from that rule's own lines; none is invented:
@@ -304,13 +306,9 @@ NEGATED = re.compile(
304
306
  # carve-out does not reopen the end: "The user asks to explain or walk through:
305
307
  # explain fully. No preamble, no closer, but the body runs as long as the topic
306
308
  # needs" - only the body's length is freed.
307
- SYCOPHANT = re.compile(
308
- r"^\s*(?:(?:evet|yes|ah|oh|hmm)[,!\s]+)?"
309
- r"(?:haklısın|haklısınız|you'?re (?:absolutely )?right|you are right|"
310
- r"absolutely right|good catch|great catch|iyi yakaladın|"
311
- r"detaylı bakmadım|i didn'?t look closely|i should have checked|"
312
- r"my mistake|my bad|sorry, i|"
313
- r"great question|let me\b|i['’]ll\b|sure[!,.]|looking at your\b)", re.I)
309
+ SYCOPHANT = re.compile(r"^\s*(?:(?:%s)[,!\s]+)?(?:%s)"
310
+ % (alts("sycophant", "interjection"), alts("sycophant", "opener")),
311
+ re.I)
314
312
  # The closer half of the same rule. Read on the reply's last PROSE line only
315
313
  # (`_closing_prose`), so a reply that ends on a code block, a heading or a table
316
314
  # ends on structure and is not judged here at all. Accepted false positive: a
@@ -318,9 +316,7 @@ SYCOPHANT = re.compile(
318
316
  # reads the same as making the sign-off. The skill's carve-out does not reopen
319
317
  # this end either: an explain/walkthrough run keeps "No preamble, no closer", and
320
318
  # the question rule 5 surfaces at the end is the user's question, not a sign-off.
321
- CLOSER = re.compile(
322
- r"let me know if you need anything else|hope (?:this|that) helps|"
323
- r"happy to clarify", re.I)
319
+ CLOSER = re.compile(alts("closer"), re.I)
324
320
  # The reply shapes the contract bans and nothing counted: a reply that opens on a
325
321
  # table, and one that closes on a recap instead of a next action. Read by
326
322
  # `shape_flags` for a report-only row - a table is sometimes the right answer and
@@ -331,9 +327,7 @@ HEADING = re.compile(r"^\s*#{1,6}\s")
331
327
  FENCE = re.compile(r"^\s*(?:```|~~~)")
332
328
  # A closing line that names what comes next is the shape the contract asks for,
333
329
  # so its absence on a long reply's last line is what `recap-close` records.
334
- NEXT_ACTION = re.compile(
335
- r"(?i)(?:next (?:step|action|up)|sıradaki adım|sonraki adım|next:)"
336
- r"|^\s*(?:→|->)\s")
330
+ NEXT_ACTION = re.compile(r"(?i)(?:%s)|^\s*(?:→|->)\s" % alts("next_action"))
337
331
  # A reply longer than this that closes on a recap is `recap-close`.
338
332
  SHAPE_LINES = 15
339
333
  # A top-level list item: a marker in column 0, the shape rules 2 and 8 count (a
@@ -345,22 +339,19 @@ ITEM = re.compile(r"^(?:\d+[.)]|[-*+])\s")
345
339
  # escape for a list whose completeness is the point is "headers so it can be
346
340
  # skimmed", and a heading ends the run below, as does any prose line.
347
341
  LIST_CAP = 5
348
- # The reply-language half of the exec rule ("Every user-facing reply in
349
- # Turkish"). A word list and six letters are not a language detector; they only
350
- # have to separate Turkish prose from English prose, which differ sharply on
351
- # both. Calibrated on the owner's omp transcripts (2026-09-27: 16,511 assistant
352
- # messages, 1,495 with at least LANG_MIN_WORDS prose words): English prose
353
- # scored 0.000-0.026, the most English-heavy Turkish reply 0.077 and the bulk
354
- # 0.3-0.7, so the cut sits between the two; 24 of the 1,495 fall under it, all
355
- # English or Chinese prose. Prose shorter than LANG_MIN_WORDS is not judged - a
356
- # one-line answer, a path or a command list is too short to call.
357
- TR_LETTERS = re.compile(r"[çğıöşüÇĞİÖŞÜ]")
358
- TR_WORDS = frozenset((
359
- "ve", "bir", "bu", "şu", "için", "ile", "da", "de", "ama", "fakat", "ya",
360
- "veya", "ne", "mi", "mı", "mu", "mü", "çok", "daha", "en", "gibi", "kadar",
361
- "sonra", "önce", "var", "yok", "değil", "olarak", "olan", "her", "hem",
362
- "ise", "ki", "artık", "hâlâ", "hala", "şimdi", "yani", "diye", "tüm",
363
- "bütün", "kalan", "neden", "nasıl", "yeni", "eski", "evet", "hayır"))
342
+ # The reply-language half of the exec rule. A word list and a letter set are
343
+ # not a language detector; they only have to separate the configured
344
+ # language's prose from English, which differ sharply on both: a pack's
345
+ # `markers` (`reply_share`), and a language whose pack ships none is not
346
+ # judged. The tr markers are calibrated on the owner's omp transcripts
347
+ # (2026-09-27: 16,511 assistant messages, 1,495 with at least LANG_MIN_WORDS
348
+ # prose words): English prose scored 0.000-0.026, the most English-heavy
349
+ # Turkish reply 0.077 and the bulk 0.3-0.7, so the cut sits between the two;
350
+ # 24 of the 1,495 fall under it, all English or Chinese prose. The short words
351
+ # Spanish, French and Dutch share with Turkish (de, da, en, ne, ya, her, mi)
352
+ # left the list (S5 F8: they passed those replies as Turkish at 0.07-0.31).
353
+ # Prose shorter than LANG_MIN_WORDS is not judged - a one-line answer, a path
354
+ # or a command list is too short to call.
364
355
  PROSE_WORD = re.compile(r"[^\W\d_]+", re.U)
365
356
  # Text inside a reply that is not prose in any language: inline code, a URL,
366
357
  # a path, a dotted or snake_case identifier, a `path:line` citation.
@@ -370,14 +361,18 @@ LANG_MIN_SHARE = 0.06
370
361
  # A lead that announces what follows instead of being it (rule 1 "Lead with the
371
362
  # answer", rule 10 "No preamble"). Report-only: "Sonuç:" over a list is an
372
363
  # answer label, not a preamble, and the two read the same to a regex.
373
- PREAMBLE = re.compile(
374
- r"(?i)^\s*(?:here(?:'s| is| are)\b|below\b|the following\b|"
375
- r"aşağıda|şöyle\b|öncelikle\b|şimdi\b)")
364
+ PREAMBLE = re.compile(r"(?i)^\s*(?:%s)" % alts("preamble"))
376
365
  # The Stop classes that judge how a reply is written, not whether its claim has
377
366
  # evidence. They stay `claim` rows (the verdict is one row per reply) but are not
378
367
  # false completions: counters folds them into `shape_blocked` instead.
379
368
  SHAPE_BLOCKS = frozenset(("placating opener", "forbidden closer", "list cap",
380
369
  "reply language"))
370
+ # The Stop classes that refuse the work a reply left, not its claim: a backed
371
+ # "testler geçti" beside "Açık kalanlar", a stopped slice or a question the
372
+ # agent could decide is no false completion. Counted in `blocked_claims` under
373
+ # their own class, kept out of `false_completion`.
374
+ LIST_BLOCKS = frozenset(("residual work", "ready steps", "stopped slice",
375
+ "needless ask"))
381
376
 
382
377
  WRITE_TOOLS = ("edit", "write", "multiedit", "notebookedit", "apply_patch",
383
378
  "str_replace_editor", "create_file", "str_replace", "edit_file",
@@ -428,7 +423,9 @@ USER_ERROR = re.compile(
428
423
  r"authentication (?:failed|required)|"
429
424
  r"(?:invalid|missing|no|bad|expired) (?:api[ _-]?key|credentials?)|"
430
425
  r"(?:api[ _-]?key|token|credentials?) (?:is |are |has )?(?:invalid|missing|expired)|"
431
- r"not logged in|login required", re.I)
426
+ r"not logged in|login required|"
427
+ # a spent quota is a grant only the user can renew (S4 finding 6)
428
+ r"quota|key limit exceeded|usage limit", re.I)
432
429
  PERMANENT_ERROR = re.compile(
433
430
  r"command not found|no such file|not found|cannot find|permission denied|"
434
431
  r"unrecognized|unknown option|invalid|syntax error|does not exist|"
@@ -483,6 +480,18 @@ PERMANENT_ERROR = re.compile(
483
480
  # made mid-session only when one of these rows names it. `inflight` marks a
484
481
  # `turn` row written while a call was still unanswered (`_in_flight`): it
485
482
  # slices the turn like any other, and it is no boundary for `_in_flight`.
483
+ # `reask` is a Stop refusal row's `_prompt_key` of its own text: the key the
484
+ # `turn` row carries when a host hands the refusal back as a prompt (plan 083).
485
+ # `open`, `ready` and `blocked` are a `todo` row's counts of the host's todo list
486
+ # (`note_todo`), and `heads` the list of its ready items' heads, each redacted:
487
+ # `detail` holds the same heads, one per line, under the row's cut.
488
+ # `residual` is a `subagent_end` row's count of residual items no blocker type
489
+ # accounts for (`unblocked_items`): the parent's Stop owes them (S4 finding 2).
490
+ # `questions` and `defaults` are an `ask` row's counts (`note_ask`): the
491
+ # questions an ask-tool call put to the user and how many carried a
492
+ # recommended option. A `grant` row's `target` is the outward class a user's
493
+ # grant covers (`all`, or an OUTWARD key), or `lift:<class>` when a prompt took
494
+ # it back (`grants_in_force`).
486
495
  LEDGER_FIELDS = frozenset(("id", "exit", "out_bytes", "fail_class", "workspace",
487
496
  "source", "hash", "changed", "tool", "target",
488
497
  "lines", "chars", "items", "longest_list",
@@ -490,7 +499,9 @@ LEDGER_FIELDS = frozenset(("id", "exit", "out_bytes", "fail_class", "workspace",
490
499
  "summary_chars", "summary_hash",
491
500
  "constraint_found", "constraint_expected", "parent", "agent",
492
501
  "check", "key", "block", "repo", "empty_run", "cause",
493
- "host", "switches", "harness", "authorized", "inflight"))
502
+ "host", "switches", "harness", "authorized", "inflight",
503
+ "reask", "open", "ready", "blocked", "heads", "residual",
504
+ "questions", "defaults"))
494
505
 
495
506
  # The row contract's own version, stamped by the writer beside `kind` and `ts` so
496
507
  # it is not a caller field. It exists because a row is read back to decide a
@@ -900,8 +911,9 @@ DAMAGE_KIND = "ledger_damage"
900
911
  TAMPERED = (
901
912
  "Evidence tampered: a line of this session's evidence ledger is not a row "
902
913
  "(recorded as `ledger_damage`), so the ledger cannot carry a done or tested "
903
- "claim this turn. Say what is unverified (\"doğrulanmadı\") and tell the "
904
- "user the ledger was damaged; the ledger is theirs to inspect.")
914
+ "claim this turn, nor the failed check an admission rests on. Drop the "
915
+ "claim, say what is unverified and tell the user the ledger was damaged; "
916
+ "the ledger is theirs to inspect.")
905
917
  # The damaged lines this process already recorded, so a reader that parses the
906
918
  # same tail on every gated call scans the file for the row once, not each time.
907
919
  _DAMAGE_SEEN = set()
@@ -1167,9 +1179,10 @@ def scratch_target(path, cwd=None):
1167
1179
  return False
1168
1180
 
1169
1181
 
1170
- def writers_elsewhere(path, session_id, minutes=10, cwd=None):
1182
+ def writers_elsewhere(path, session_id, minutes=10, cwd=None, lineage=False):
1171
1183
  """The other sessions that recorded a write of `path` in the last `minutes`,
1172
- newest first, as ledger ids.
1184
+ newest first, as ledger ids. With `lineage`, each one is a (ledger id,
1185
+ relation, agent, newest write ts) tuple instead (`_relation`).
1173
1186
 
1174
1187
  `path` is resolved against `cwd` (`_abs_target`) and matched against the
1175
1188
  `target` the writing session's PostToolUse hook stored, which is that
@@ -1214,7 +1227,74 @@ def writers_elsewhere(path, session_id, minutes=10, cwd=None):
1214
1227
  continue
1215
1228
  if isinstance(row, dict) and row.get("target") == want:
1216
1229
  found[stem] = max(found.get(stem, stamp), stamp)
1217
- return sorted(found, key=lambda stem: -found[stem])
1230
+ stems = sorted(found, key=lambda stem: -found[stem])
1231
+ return [(stem,) + _relation(stem, session_id) + (found[stem],)
1232
+ for stem in stems] if lineage else stems
1233
+
1234
+
1235
+ # The rows that name a session's lineage at its start: `spawned` (a subagent's
1236
+ # parent, written by the hosts' subagent start) and `forked` (an omp fork's
1237
+ # parent, written by the omp hook from the session header's `parentSession`).
1238
+ LINEAGE_KINDS = ("spawned", "forked")
1239
+
1240
+
1241
+ def _lineage(stem):
1242
+ """{kind: (parent ledger id, raw parent id, agent)} of the first row of each
1243
+ LINEAGE_KINDS in ledger `stem`; {} when it has none or cannot be read."""
1244
+ out = {}
1245
+ for kind in LINEAGE_KINDS:
1246
+ try:
1247
+ texts = ts.evidence_rows(os.path.join(cache_dir(), "evidence",
1248
+ stem + ".jsonl"), kind=kind)
1249
+ except ts.ERRORS:
1250
+ continue
1251
+ for text in texts:
1252
+ try:
1253
+ row = json.loads(text)
1254
+ except ValueError:
1255
+ continue
1256
+ if isinstance(row, dict) and row.get("parent"):
1257
+ raw = str(row["parent"])
1258
+ out[kind] = (_slug(raw), raw, str(row.get("agent") or ""))
1259
+ break
1260
+ return out
1261
+
1262
+
1263
+ def _relation(stem, session_id):
1264
+ """(relation, agent) of writer ledger `stem` to session `session_id`: `child`
1265
+ (its `spawned` row names this session; agent is the child's name),
1266
+ `parent`, `sibling` (both spawned by one parent), `fork` (an omp fork of
1267
+ this session), `forked-from` (the session this one was forked from), else
1268
+ `unrelated`. The race rule names it and exempts a child that has ended
1269
+ (research orchestrator-flow-audit S7, findings 3 and 4)."""
1270
+ theirs, mine = _lineage(stem), _lineage(_slug(session_id))
1271
+ spawn, fork = theirs.get("spawned"), theirs.get("forked")
1272
+ if spawn and spawn[1] == session_id:
1273
+ return "child", spawn[2]
1274
+ if fork and fork[1] == session_id:
1275
+ return "fork", ""
1276
+ own_spawn, own_fork = mine.get("spawned"), mine.get("forked")
1277
+ if own_spawn and own_spawn[0] == stem:
1278
+ return "parent", ""
1279
+ if own_fork and own_fork[0] == stem:
1280
+ return "forked-from", ""
1281
+ if spawn and own_spawn and spawn[1] == own_spawn[1]:
1282
+ return "sibling", spawn[2]
1283
+ return "unrelated", ""
1284
+
1285
+
1286
+ def spawn_of(session_id):
1287
+ """(raw parent session id, spawned name) from this session's `spawned` row,
1288
+ else None: how a subagent's own session finds its slice record (the lease
1289
+ rule, `tezgah_gate.lease_reason`). The name is omp's child file stem, so a
1290
+ re-dispatch reads `<name>-2`."""
1291
+ hit = _lineage(_slug(session_id)).get("spawned") if session_id else None
1292
+ return (hit[1], hit[2]) if hit and hit[2] else None
1293
+
1294
+
1295
+ def _prompt_key(text):
1296
+ """The key a `turn` row stores for one prompt text (`note_turn`)."""
1297
+ return hashlib.sha1(text.encode("utf-8", "replace")).hexdigest()[:12]
1218
1298
 
1219
1299
 
1220
1300
  def note_turn(session_id, prompt, workspace=None):
@@ -1246,7 +1326,7 @@ def note_turn(session_id, prompt, workspace=None):
1246
1326
  written in between, resets nothing - that direction can only deny too much,
1247
1327
  never too little."""
1248
1328
  text = str(prompt or "")
1249
- key = hashlib.sha1(text.encode("utf-8", "replace")).hexdigest()[:12]
1329
+ key = _prompt_key(text)
1250
1330
  rows = events(session_id, tail=1)
1251
1331
  if rows and rows[-1].get("kind") == "turn" and rows[-1].get("detail") == key:
1252
1332
  return
@@ -1563,10 +1643,12 @@ def counters(session_id, weeks=False, tools=False):
1563
1643
  carries, because a host that reports none would otherwise read as a perfect
1564
1644
  success rate while opencode's real process codes (2, 127, 130) count in
1565
1645
  neither half. `steps` counts the work rows only (STEP_KINDS); and
1566
- `false_completion` counts the claim rows a stop refused, so the rate is
1567
- false_completion / claims. `judge` counts the seam's own rows by kind: a
1568
- judgement is a cost, so it is in neither STEP_KINDS nor the check set - it
1569
- can never make a "done" claim look backed. `shape` counts the report-only
1646
+ `false_completion` counts the claim rows a stop refused for their evidence
1647
+ - not for their shape (`SHAPE_BLOCKS`) or the work they leave
1648
+ (`LIST_BLOCKS`) - so the rate is false_completion / claims. `judge` counts
1649
+ the seam's own rows by kind: a judgement is a cost, so it is in neither
1650
+ STEP_KINDS nor the check set - it can never make a "done" claim look
1651
+ backed. `shape` counts the report-only
1570
1652
  reply-shape rows (`shape_flags`) by kind for the same reason: a flag nothing
1571
1653
  counted was indistinguishable from a flag that never fires.
1572
1654
 
@@ -1734,8 +1816,9 @@ def _counts(rows, weeks=False, tools=False):
1734
1816
  cls = detail[len("blocked: "):]
1735
1817
  out["blocked_claims"][cls] = \
1736
1818
  out["blocked_claims"].get(cls, 0) + 1
1737
- out["false_completion"] += 1
1738
- refused = True
1819
+ if cls not in LIST_BLOCKS:
1820
+ out["false_completion"] += 1
1821
+ refused = True
1739
1822
  if weeks:
1740
1823
  # the same bucket this row's events went into, so a claim cannot
1741
1824
  # sit in a week's total and outside its ratio
@@ -3178,9 +3261,55 @@ def unanswered(rows, delivered=()):
3178
3261
  return sum(1 for row in waiting if row.get("tool") in seen)
3179
3262
 
3180
3263
 
3264
+ # The host todo list is the orchestrator's step queue (plan 086, S1): each open
3265
+ # item is ready, in progress or blocked. These tools carry the whole list: omp's
3266
+ # `todo` in its result (`details.phases`, which the bridge sends as `todo`, since
3267
+ # the call holds one op), Claude's and dsh's `TodoWrite`, opencode's `todowrite`
3268
+ # and Cursor's `todo_write` in `todos[]`, Codex's `update_plan` in `plan[]`.
3269
+ TODO_TOOLS = frozenset(("todo", "todowrite", "todo_write", "update_plan"))
3270
+ # The statuses that close an item. `pending` is ready; `in_progress` is open and
3271
+ # taken, so it is not ready; omp's `blocked` (an item with a `blocker`) is open
3272
+ # and waits on something named.
3273
+ TODO_CLOSED = frozenset(("completed", "done", "cancelled", "abandoned"))
3274
+ TODO_ROW = re.compile(r'"kind"\s*:\s*"todo"')
3275
+ # How much of each ready item the row keeps: a head that names it in a refusal.
3276
+ TODO_HEAD = 60
3277
+
3278
+
3279
+ def note_todo(session_id, tool, inp, snapshot=None, cwd=None, agent=None):
3280
+ """Write one `todo` row for a todo call: the `open`, `ready` and `blocked`
3281
+ counts, every ready item's head in `heads` (redacted, TODO_HEAD characters
3282
+ each) and the same heads in `detail`, one per line, which the row's cut may
3283
+ shorten. A call whose list is not in hand (an omp `todo` result without
3284
+ its `details`) writes nothing, so it cannot read as an empty queue. An item
3285
+ whose own text types a blocker (KARAR, YETKİ, GERİ ALINAMAZ, ORTAM, or
3286
+ `blocked`) counts as blocked: Claude's and Codex's lists have no blocked
3287
+ status. `agent` is the host's subagent id: a subagent's list is its own,
3288
+ and the readers skip it. ponytail: a Cursor `merge: true` call carries
3289
+ only the items it changes and is recorded as the whole list, so its counts
3290
+ can run low."""
3291
+ raw = snapshot if snapshot is not None else inp.get("todos", inp.get("plan"))
3292
+ if not isinstance(raw, list):
3293
+ return
3294
+ items = [(str(i.get("content") or i.get("step") or "").strip(),
3295
+ "blocked" if i.get("blocker") else str(i.get("status") or "pending").lower())
3296
+ for i in raw if isinstance(i, dict)]
3297
+ items = [(text, status) for text, status in items
3298
+ if text and status not in TODO_CLOSED]
3299
+ blocked = [text for text, status in items if status == "blocked" or
3300
+ BLOCKER_SAID.search(text) or BLOCKER_TRIED.search(text)
3301
+ or re.search(r"\bblocked\b", text, re.I)]
3302
+ heads = [_stored_text(" ".join(text.split()))[:TODO_HEAD] for text, status in items
3303
+ if status == "pending" and text not in blocked]
3304
+ note(session_id, "todo", "\n".join(heads),
3305
+ id=call_id(tool, inp), tool=str(tool or "").strip() or None,
3306
+ workspace=root_for(cwd) if cwd else None, agent=agent or None,
3307
+ open=len(items), ready=len(heads), blocked=len(blocked), heads=heads)
3308
+
3309
+
3181
3310
  def note_tool(session_id, tool, inp, failed=None, *, interrupted=False,
3182
3311
  out_bytes=None, error=None, cwd=None, source=None, empty_run=False,
3183
- agent=None):
3312
+ agent=None, host=None, todo=None, child=None):
3184
3313
  """Record the evidence kind for one tool call (host PostToolUse hooks).
3185
3314
 
3186
3315
  `failed=None` is the default because a host that passes no argument reported
@@ -3224,8 +3353,24 @@ def note_tool(session_id, tool, inp, failed=None, *, interrupted=False,
3224
3353
  check exited 0 over nothing, so it records as one that RAN (`verify`), the
3225
3354
  piped check's kind, and the turn still owes a pass.
3226
3355
  `agent` is the host's subagent id (Claude's `agent_id`), written into the
3227
- row's `agent` field so the turn readers can key on (session, agent)."""
3356
+ row's `agent` field so the turn readers can key on (session, agent).
3357
+ `host` names the calling host for the slice journal (`_note_slice`); None
3358
+ reads it off the hook process (`_hook_host`). `child` is the id a spawn's
3359
+ result gave a background child (Claude's `agentId`), stored on its slice
3360
+ record so the lease rule finds it. A todo call (TODO_TOOLS)
3361
+ writes its `todo` row instead (`note_todo`), from `todo` when the host's
3362
+ list is in the result rather than the call (omp). omp's `wait` writes a
3363
+ `wait` row: no step, the call the idle-wait warning counts as idle."""
3228
3364
  inp = inp or {}
3365
+ name = str(tool or "").strip().lower()
3366
+ if name in TODO_TOOLS:
3367
+ if not failed and not interrupted:
3368
+ note_todo(session_id, tool, inp, todo, cwd, agent)
3369
+ return
3370
+ if name == "wait":
3371
+ note(session_id, "wait", "", id=call_id(tool, inp), tool="wait",
3372
+ workspace=root_for(cwd) if cwd else None)
3373
+ return
3229
3374
  cmd = str(inp.get("command") or inp.get("cmd") or "")
3230
3375
  kind = classify(tool, inp)
3231
3376
  if kind == "verify":
@@ -3314,6 +3459,7 @@ def note_tool(session_id, tool, inp, failed=None, *, interrupted=False,
3314
3459
  fields["repo"] = _check_repo(cmd, cwd, inp)
3315
3460
  fields["empty_run"] = True if empty_run else None
3316
3461
  note(session_id, kind, detail, **fields)
3462
+ _note_slice(session_id, tool, inp, failed, interrupted, error, cwd, host, child)
3317
3463
  if kind == "edit" and not failed:
3318
3464
  # the taste capture (tezgah_taste, opt-in): the edit's text under this
3319
3465
  # row's id, and the bytes it left - read here because only PostToolUse
@@ -3325,6 +3471,84 @@ def note_tool(session_id, tool, inp, failed=None, *, interrupted=False,
3325
3471
  tezgah_taste.note_write(session_id, fields["id"], inp, cwd)
3326
3472
 
3327
3473
 
3474
+ # The hosts whose post event for a spawn call arrives once the child finished,
3475
+ # so the call's outcome is the slice's: Claude/dsh/Cursor `Agent`/`Task`. omp's
3476
+ # `task` and Codex `spawn_agent` return while the child still runs (a Codex
3477
+ # rollout shows the call answered with its `task_name` alone), so their records
3478
+ # stay open: omp's for `tezgah_task.pending` to close from the child
3479
+ # transcripts, Codex's until `pending` closes them `stale`.
3480
+ SYNC_SPAWN_HOSTS = frozenset(("claude", "dsh", "cursor"))
3481
+
3482
+
3483
+ def _journal():
3484
+ """The slice journal module (tezgah_task), or None when it is missing or
3485
+ broken: the journal is a record, and the post-tool path must never pay for
3486
+ it. Imported here, not at the top: tezgah_task is newer than some checkouts."""
3487
+ try:
3488
+ import tezgah_task
3489
+ except Exception:
3490
+ return None
3491
+ return tezgah_task
3492
+
3493
+
3494
+ def _hook_host():
3495
+ """The host this hook process runs for (`tezgah_guard._host`): `hosts/<name>/`
3496
+ by its directory, and the shared Claude/dsh script told apart by the dsh
3497
+ manifest's `TEZGAH_CALL_OUTCOME=none`."""
3498
+ try:
3499
+ from tezgah_guard import _host
3500
+ name = _host()
3501
+ except Exception:
3502
+ return ""
3503
+ if name == "claude/dsh":
3504
+ return "dsh" if os.environ.get("TEZGAH_CALL_OUTCOME") == "none" else "claude"
3505
+ return name
3506
+
3507
+
3508
+ def _note_slice(session_id, tool, inp, failed, interrupted, error, cwd, host,
3509
+ child=None):
3510
+ """Hand a spawn call to the slice journal (`tezgah_task.note_spawn`), and
3511
+ open a `job` record for an omp `bash` call sent to the background, so a
3512
+ `/clear` keeps the pointer to the run. Swallows every exception.
3513
+
3514
+ On a synchronous host the slice is closed here: the event arriving means
3515
+ the child is no longer running, so a call with no failure signal (dsh's
3516
+ bridge sends none) closes as done rather than staying open forever - a
3517
+ journal entry, never a pass on the ledger. A Claude call sent with
3518
+ `run_in_background`, or whose result names a background `child`, returns
3519
+ before its child ends and stays open."""
3520
+ name = str(tool or "").strip().lower()
3521
+ if not cwd:
3522
+ return
3523
+ try:
3524
+ tt = _journal()
3525
+ host = host or _hook_host()
3526
+ if name in SUBAGENT_TOOLS:
3527
+ spawn = getattr(tt, "note_spawn", None)
3528
+ if spawn is None:
3529
+ return
3530
+ sync = (host in SYNC_SPAWN_HOSTS and inp.get("run_in_background") is not True
3531
+ and not child)
3532
+ text = str(error or "") or ("aborted" if interrupted else "")
3533
+ # Codex's hook maps `spawn_agent` onto `Task` before this row; the
3534
+ # journal parses the call by the host's own name and fields
3535
+ spawn(cwd, session_id, host,
3536
+ "spawn_agent" if host == "codex" else tool, inp,
3537
+ failed=bool(failed or interrupted or error) if sync else None,
3538
+ result_text=text, **({"child": child} if child else {}))
3539
+ elif host == "omp" and name == "bash" and inp.get("async") is True:
3540
+ opener = getattr(tt, "open_slice", None)
3541
+ root = _toplevel(os.path.realpath(cwd))
3542
+ cmd = str(inp.get("command") or "")
3543
+ if opener is None or not root or not cmd or off("slices-off"):
3544
+ return
3545
+ opener(root, "job-" + hashlib.sha1(cmd.encode()).hexdigest()[:10],
3546
+ kind="job", host="omp", brief=cmd, parent=str(session_id or ""),
3547
+ cwd=cwd)
3548
+ except Exception:
3549
+ pass
3550
+
3551
+
3328
3552
  # One leading step a check's command opens with: a subshell paren, an
3329
3553
  # assignment (`export X=1`, `X=1`) or a `cd X`, closed by `&&` or `;`.
3330
3554
  LEAD_STEP = re.compile(r"\s*\(?\s*(?:(?:export\s+)?\w+=\S*|cd\s+(\S+))\s*(?:&&|;)")
@@ -3399,9 +3623,7 @@ def ran_nothing(result):
3399
3623
  # `claims` is the false-done reader the benchmarks count with; the Stop verdict
3400
3624
  # and its claim row read the vocabulary (`asserted_claims`), where an admitted
3401
3625
  # claim is a claim NEGATED lets through.
3402
- ADMITTED = re.compile(
3403
- r"contradict|çeliş|unsatisfiable|(?:can ?not|can't|can ?never|never) pass|"
3404
- r"geçemez|impossible|imk[aâ]nsız", re.I)
3626
+ ADMITTED = re.compile(alts("admitted"), re.I)
3405
3627
 
3406
3628
 
3407
3629
  def claims(text):
@@ -3420,10 +3642,10 @@ def claims(text):
3420
3642
  # the clause after it. So "Don't worry, it's done.", "All tests pass so should
3421
3643
  # I open the PR?", "Done (no regressions?)" and "Tamamlandı, push edeyim mi?"
3422
3644
  # all still assert.
3423
- NEGATION_BEFORE = re.compile(r"(?:\bnot|n't|\bnever|\bdeğil)(?:\s+\S+){0,2}\s+\Z",
3645
+ NEGATION_BEFORE = re.compile(r"(?:%s)(?:\s+\S+){0,2}\s+\Z" % alts("negation_before"),
3424
3646
  re.I)
3425
- QUESTION_PARTICLE = re.compile(r"\s*(?:m[ıiuü]|değil)\b", re.I)
3426
- CLAUSE_END = re.compile(r"[.!?\n,;:\u2014\u2013()]|\b(?:so|and|but|ama|ve)\b",
3647
+ QUESTION_PARTICLE = re.compile(r"\s*(?:%s)\b" % alts("question_particle"), re.I)
3648
+ CLAUSE_END = re.compile(r"[.!?\n,;:\u2014\u2013()]|\b(?:%s)\b" % alts("clause_join"),
3427
3649
  re.I)
3428
3650
 
3429
3651
 
@@ -3763,9 +3985,11 @@ def last_verify(session_id):
3763
3985
  return _last_verify(events(session_id))
3764
3986
 
3765
3987
 
3766
- def last_check(session_id):
3988
+ def last_check(session_id, repo=None):
3767
3989
  """The newest verification row the ledger holds - a `verify_ok` or a
3768
3990
  `verify_fail`, with its command in `detail` - or None when no check ran.
3991
+ With `repo`, a check whose own `repo` names another repository is skipped
3992
+ (`_same_repo`): a check run in another project says nothing about this one.
3769
3993
 
3770
3994
  `last_verify` answers the same question as a state; this hands the row back
3771
3995
  for a reader that has to name the command the state came from, the way the
@@ -3775,7 +3999,8 @@ def last_check(session_id):
3775
3999
  buys out of."""
3776
4000
  newest = None
3777
4001
  for row in events(session_id, tail=SCRATCH_TAIL):
3778
- if row.get("kind") in ("verify_ok", "verify_fail"):
4002
+ if row.get("kind") in ("verify_ok", "verify_fail") \
4003
+ and _same_repo(row.get("repo"), repo):
3779
4004
  newest = row
3780
4005
  return newest
3781
4006
 
@@ -3935,15 +4160,34 @@ def prose_words(text):
3935
4160
  return words
3936
4161
 
3937
4162
 
3938
- def turkish_share(words):
3939
- """The share of `words` that mark Turkish: a Turkish letter in the word, or
3940
- a word from TR_WORDS. None for an empty list."""
3941
- if not words:
4163
+ def _markers(code):
4164
+ """Pack `code`'s reply markers as (words, letters), or None when it ships
4165
+ none: only such a language's replies are held to it."""
4166
+ marks = packs().get(code, {}).get("markers") or {}
4167
+ words, letters = frozenset(marks.get("words", ())), "".join(marks.get("letters", ()))
4168
+ return (words, letters) if words or letters else None
4169
+
4170
+
4171
+ def reply_share(words, code="tr"):
4172
+ """The share of `words` that mark language `code` (`_markers`): a marker
4173
+ letter in the word, or a marker word. None for an empty list or a language
4174
+ with no markers."""
4175
+ marks = _markers(code)
4176
+ if not words or not marks:
3942
4177
  return None
3943
- hits = sum(1 for w in words if w in TR_WORDS or TR_LETTERS.search(w))
4178
+ hits = sum(1 for w in words if w in marks[0] or any(c in marks[1] for c in w))
3944
4179
  return round(hits / len(words), 3)
3945
4180
 
3946
4181
 
4182
+ def _said(text):
4183
+ """`text` with each `{WORD}` placeholder filled with the configured
4184
+ language's contract word (`tezgah_paths.say`): the refusal names the
4185
+ tokens the contract the model was given names."""
4186
+ for key, word in say().items():
4187
+ text = text.replace("{%s}" % key.upper(), word)
4188
+ return text
4189
+
4190
+
3947
4191
  def reply_shape(text):
3948
4192
  """The reply's size and lead, as the `shape` row records them for every
3949
4193
  judged reply and the `claim` row beside its verdict: `lines` (line count),
@@ -3971,7 +4215,7 @@ def reply_shape(text):
3971
4215
  return {"lines": len(lines), "chars": len(text),
3972
4216
  "items": sum(1 for line in lines if ITEM.match(line)),
3973
4217
  "longest_list": longest_list(text),
3974
- "tr_share": turkish_share(prose_words(text)),
4218
+ "tr_share": reply_share(prose_words(text)),
3975
4219
  "answer_first": answer_first}
3976
4220
 
3977
4221
 
@@ -4045,8 +4289,7 @@ def _shape_block(text, cwd):
4045
4289
  "Start with the answer: \"Great question\" / \"Let me...\" / "
4046
4290
  "\"I'll...\" / \"Sure!\" / \"Looking at your...\" are never "
4047
4291
  "the first line. If the user is right, state the fact and the "
4048
- "fix in one plain sentence - never \"haklısın\" / \"you're "
4049
- "right\" / \"detaylı bakmadım\" / an apology.")
4292
+ "fix in one plain sentence - {OPENER_BAN}.")
4050
4293
  # only the last prose line is read, so a reply that ends on a code
4051
4294
  # block, a heading or a table is not judged here at all
4052
4295
  last = _closing_prose(lines)
@@ -4055,8 +4298,8 @@ def _shape_block(text, cwd):
4055
4298
  "Reply closes with a sign-off the output contract bans "
4056
4299
  "(rule 10: no preamble, no recap, no closer): \"Let me know "
4057
4300
  "if you need anything else\" / \"Hope this helps\" / \"Happy "
4058
- "to clarify\". End when the answer is done; if something is "
4059
- "still open, name the one next step instead.")
4301
+ "to clarify\". End when the answer is done; what is left is "
4302
+ "done now, or named under \"{BLOCKERS_HEADING}\" as a blocker.")
4060
4303
  longest = longest_list(text)
4061
4304
  if longest > LIST_CAP:
4062
4305
  return ("list cap",
@@ -4067,25 +4310,97 @@ def _shape_block(text, cwd):
4067
4310
  "whole enumeration is the point, split it under headings of "
4068
4311
  "at most five items each, or give it as a table."
4069
4312
  % (longest, LIST_CAP))
4070
- # `reply_lang` is the switch, and only `tr` is judged: this is a Turkish
4071
- # detector, and an English reply carries the contract's own hedge word or
4072
- # quotes the user's Turkish, so it cannot hold a reply to English (`en`).
4073
- if reply_lang() == "tr" and not off("exec-mode.off"):
4313
+ # `language` is the switch, and only a pack that ships reply markers (tr)
4314
+ # is judged: the markers tell that language from English, and an English
4315
+ # reply quotes the user's other language, so English is not held to.
4316
+ if _markers(language()) and not off("exec-mode.off"):
4074
4317
  words = prose_words(text)
4075
- share = turkish_share(words)
4318
+ share = reply_share(words, language())
4319
+ name = packs()[language()]["name"]
4076
4320
  if len(words) >= LANG_MIN_WORDS and share < LANG_MIN_SHARE:
4077
4321
  return ("reply language",
4078
- "Reply prose is not in Turkish (%d prose words, %.0f%% of "
4079
- "them Turkish). The contract's first rule: every user-facing "
4080
- "reply is in Turkish, even when the user writes English. "
4081
- "Rewrite the prose in Turkish; code, commands, paths, "
4322
+ "Reply prose is not in %s (%d prose words, %.0f%% of "
4323
+ "them %s). The contract's first rule: every user-facing "
4324
+ "reply is in %s, even when the user writes English. "
4325
+ "Rewrite the prose in %s; code, commands, paths, "
4082
4326
  "identifiers and quoted output stay as they are (put them in "
4083
- "backticks or a code block)." % (len(words), share * 100))
4327
+ "backticks or a code block)."
4328
+ % (name, len(words), share * 100, name, name, name))
4084
4329
  return (None, None)
4085
4330
 
4086
4331
 
4087
- def stop_reason(text, session_id, cwd=None, record_only=False,
4088
- subagent=False, agent=None):
4332
+ # How many times the Stop rule may refuse inside one user turn (plan 083): the
4333
+ # reply after a block is judged again and refused once more only for a class
4334
+ # the turn has not been refused for. Counted from the ledger's own `blocked:`
4335
+ # rows (`_turn_refusals`, `_record_only`), so every host gets the same budget
4336
+ # whatever its payload says about earlier blocks.
4337
+ STOP_REASKS = 2
4338
+
4339
+
4340
+ def _blocked(entry):
4341
+ """True for a Stop refusal row: a `blocked:` claim or refusal."""
4342
+ return (entry.get("kind") in ("claim", "refusal")
4343
+ and str(entry.get("detail", "")).startswith("blocked:"))
4344
+
4345
+
4346
+ def _turn_refusals(session_id, rows):
4347
+ """(this rule's refusal rows in the user turn `rows` belongs to, that turn's
4348
+ rows).
4349
+
4350
+ omp hands a Stop block back to the model as a queued message, and its
4351
+ prompt hook records that text as a new `turn` row (`note_turn`), so the
4352
+ reply after a block sits in a turn of its own. Such a marker carries the
4353
+ key of the refusal text (`_prompt_key`), which the refusal row stores as
4354
+ `reask`: a marker equal to the `reask` of the refusal just before it is no
4355
+ new user turn, and that turn's refusals count too. Read back no further
4356
+ than STOP_REASKS refusals, and only the lines that carry a `reask` key are
4357
+ parsed: the Stop path stays a read of the turn, not of the session
4358
+ (StopReadBound). When the chain reaches back, the rows returned run from
4359
+ the chain's first turn with the re-ask markers left out, so the reply after
4360
+ a block is judged on the turn's whole work, as on a host that writes no
4361
+ such marker (S4 finding 1)."""
4362
+ refused = [entry for entry in rows if _blocked(entry)]
4363
+ path = _path(session_id)
4364
+ lines = _ledger_lines(path)
4365
+ end = first = _turn_line(lines)
4366
+ while end and len(refused) < STOP_REASKS:
4367
+ marker = _parse([lines[end - 1]])
4368
+ start = _turn_line(lines[:end - 1])
4369
+ before = [entry for entry in _parse([line for line in lines[start:end - 1]
4370
+ if '"reask"' in line])
4371
+ if _blocked(entry)]
4372
+ if not (marker and before
4373
+ and before[-1].get("reask") == marker[0].get("detail")):
4374
+ break
4375
+ refused = before + refused
4376
+ end = start
4377
+ if end != first:
4378
+ rows = [row for row in _pair(_parse(lines[end:], path), path, lines,
4379
+ lines[:end])
4380
+ if row.get("kind") != TURN_KIND]
4381
+ return refused, rows
4382
+
4383
+
4384
+ def _record_only(refused, turns, after_block, cls):
4385
+ """True when this reply is recorded and not refused (plan 083).
4386
+
4387
+ `refused` holds the classes this rule refused in the user turn so far. A
4388
+ reply is refused only while there are fewer than STOP_REASKS of them and
4389
+ none is its class, so a class is refused at most once per turn and a
4390
+ third refusal cannot happen. `after_block` is the host's own count of the
4391
+ blocks before this reply - 1 for Claude's, Codex's and omp's
4392
+ `stop_hook_active`, Cursor's `loop_count` - and it rules only where the
4393
+ ledger cannot: a session with no `turn` row has no turn to count in, so
4394
+ there any earlier block makes the reply record-only (the one-nudge rule
4395
+ before plan 083), and a host count at the budget is record-only too."""
4396
+ if not turns:
4397
+ return bool(after_block)
4398
+ return (int(after_block or 0) >= STOP_REASKS or cls in refused
4399
+ or len(refused) >= STOP_REASKS)
4400
+
4401
+
4402
+ def stop_reason(text, session_id, cwd=None, after_block=0,
4403
+ subagent=False, agent=None, parent=None):
4089
4404
  """Why this turn must not end yet, or None. Used by the Stop hooks (Claude,
4090
4405
  Codex, omp and Cursor, which share the payload fields and the block envelope).
4091
4406
  `cwd` is the session's directory, read for the repo's `.no-adhd` mark.
@@ -4094,8 +4409,8 @@ def stop_reason(text, session_id, cwd=None, record_only=False,
4094
4409
  whole ledger: the file grows with the session, the handler runs once per
4095
4410
  turn, and the question - did this turn leave work unverified - is about the
4096
4411
  turn. A host that writes no `turn` row (no prompt hook) gets the whole
4097
- ledger here, which is what it got before the scope; it loses the bound and
4098
- nothing else.
4412
+ ledger here, which is what it got before the scope; it loses the bound, and
4413
+ `residual work` (which needs the turn's own steps) is not asked of it.
4099
4414
 
4100
4415
  The verdict is recorded as a `claim` row when the reply is in the claim
4101
4416
  vocabulary - a DONE/VERIFIED word (`asserted_claims`), blocked or not,
@@ -4106,8 +4421,8 @@ def stop_reason(text, session_id, cwd=None, record_only=False,
4106
4421
  `detail` carries the reason class - `blocked: no verify_ok`, `blocked: check
4107
4422
  failed`, `blocked: partial failure`, `blocked: stale evidence`, `blocked: no
4108
4423
  ui_ok` (both halves of the UI rule: the screen proof and the design-contract
4109
- floor), `blocked: no external read`, `blocked: evidence tampered`, the shape
4110
- classes in SHAPE_BLOCKS, or
4424
+ floor), `blocked: no external read`, `blocked: evidence tampered`,
4425
+ `blocked: residual work` (plan 080), the shape classes in SHAPE_BLOCKS, or
4111
4426
  `ok` - so which branch refused a turn is readable; a `no verify_ok` row also
4112
4427
  carries `cause` (`_no_pass_cause`). One row per reply per turn: an identical
4113
4428
  row for the same key is skipped.
@@ -4117,26 +4432,35 @@ def stop_reason(text, session_id, cwd=None, record_only=False,
4117
4432
  list and language numbers the rule refuses on have a published rate over
4118
4433
  every reply, not only over the ones that made a claim.
4119
4434
 
4120
- `record_only` is the reply after a block (`stop_hook_active`): it is judged
4121
- the same way but never refused a second time, and its verdict goes to an
4435
+ The reply after one of this rule's blocks - the turn already holds a
4436
+ `blocked:` claim or refusal row, or, in a session with no `turn` row, the
4437
+ host's `after_block` count says a block came before - is judged the same
4438
+ way. `_record_only` decides whether it may be refused again: once more in
4439
+ the turn, for a class not refused yet (plan 083). A refused one is a
4440
+ refusal like the first, `shape` row included; every other one goes to an
4122
4441
  `after_block` row - `would block: <class>`, `ok` or `no claim`, one per
4123
4442
  reply - so what a block led to is on the record without counting as a
4124
- claim or as a second reply in the shape rate. Only a turn this rule
4125
- refused has one: the flag says some Stop hook blocked, not that this one
4126
- did. `subagent` is a subagent's end (ADR 011): judged the same way, never
4127
- refused, recorded as a `subagent_end` row with the same detail vocabulary,
4128
- the host's subagent id in `agent` when it sent one, and no turn
4129
- precondition. Both return None. A reply counts as claiming only with
4130
- `asserted_claims`: "Testler geçti mi?" leaves no `claim ok` row."""
4443
+ claim or as a second reply in the shape rate. A host count with no refusal
4444
+ of this rule in the turn writes nothing: the host says some Stop hook
4445
+ blocked, not that this one did. `subagent` is a subagent's end (ADR 011):
4446
+ judged the same way, never refused, recorded as a `subagent_end` row with
4447
+ the same detail vocabulary, the host's subagent id in `agent` when it sent
4448
+ one, and no turn precondition. `parent` is the ledger that row goes to when
4449
+ the child keeps a ledger of its own (omp's task sessions); the child's own
4450
+ rows still judge it. Both return None. A reply counts as
4451
+ claiming only with `asserted_claims`: "Testler geçti mi?" leaves no
4452
+ `claim ok` row."""
4131
4453
  rows, turns = turn_rows(session_id, turns=True)
4132
- record_only = record_only or subagent
4133
- if record_only and not subagent and not any(
4134
- entry.get("kind") in ("claim", "refusal")
4135
- and str(entry.get("detail", "")).startswith("blocked:")
4136
- for entry in rows):
4454
+ chain, rows = (_turn_refusals(session_id, rows) if turns
4455
+ else (list(filter(_blocked, rows)), rows))
4456
+ refused = [str(entry.get("detail"))[len("blocked: "):] for entry in chain]
4457
+ if after_block and not subagent and not refused:
4137
4458
  return None
4138
4459
  cls, reason = _stop_block(text, session_id, rows=rows, cwd=cwd,
4139
4460
  shape=not subagent)
4461
+ record_only = subagent or bool(
4462
+ (refused if turns else after_block)
4463
+ and (not reason or _record_only(refused, turns, after_block, cls)))
4140
4464
  key = _claim_key(text, turns)
4141
4465
  # Keyed like the claim row, so Cursor's re-run of the handler on a follow-up
4142
4466
  # does not count the same reply twice; above the early return, so a reply
@@ -4173,12 +4497,28 @@ def stop_reason(text, session_id, cwd=None, record_only=False,
4173
4497
  except Exception:
4174
4498
  if os.environ.get("TEZGAH_STOPSPEC_STRICT"):
4175
4499
  raise
4500
+ # A refusal is skipped only as the same verdict on the same reply: the same
4501
+ # reply text refused for a new class is the turn's next refusal, and
4502
+ # `_record_only` counts refusals by these rows. A row bound for a parent's
4503
+ # ledger is looked up there, so one child's end is one row.
4176
4504
  if not any(entry.get("kind") == kind and entry.get("id") == key
4177
- for entry in rows):
4505
+ and (entry.get("detail") == detail or not reason)
4506
+ and (not parent or entry.get("agent") == (str(agent) if agent else None))
4507
+ for entry in (turn_rows(parent) if parent else rows)):
4178
4508
  if cls == "no verify_ok" and not record_only:
4179
4509
  shape = dict(shape, cause=_no_pass_cause(rows))
4510
+ if reason:
4511
+ # the key omp's continuation turn will carry (`_turn_refusals`)
4512
+ shape = dict(shape, reask=_prompt_key(str(reason).strip()))
4180
4513
  if subagent and agent:
4181
4514
  shape = dict(shape, agent=str(agent))
4515
+ if subagent:
4516
+ # the child is never refused (ADR 011); the parent's Stop reads
4517
+ # this count (`_residual_block`), so an untyped item handed up
4518
+ # still owes a type before the user's turn ends
4519
+ handed = len(unblocked_items(text, rows))
4520
+ if handed:
4521
+ shape = dict(shape, residual=handed)
4182
4522
  if kind == "claim":
4183
4523
  # "harness drifted" rides on the claim as a record, read from the
4184
4524
  # mark the session start left; it never refuses the turn. Imported
@@ -4190,7 +4530,7 @@ def stop_reason(text, session_id, cwd=None, record_only=False,
4190
4530
  drifted = ""
4191
4531
  if drifted:
4192
4532
  shape = dict(shape, harness="drifted: " + drifted)
4193
- note(session_id, kind, detail, id=key, **shape)
4533
+ note(parent or session_id, kind, detail, id=key, **shape)
4194
4534
  return reason
4195
4535
 
4196
4536
 
@@ -4231,18 +4571,59 @@ WORK_KINDS = frozenset(STEP_KINDS) - {"verify_ok"}
4231
4571
  # that writes would excuse an unverified change.
4232
4572
  READ_ONLY_PROGRAMS = frozenset((
4233
4573
  "ls", "cat", "head", "tail", "wc", "pwd", "echo", "printf", "which", "stat",
4234
- "du", "df", "date", "whoami", "grep", "rg", "cd", "true"))
4574
+ "du", "df", "date", "whoami", "grep", "rg", "cd", "true", "find", "jq", "sort",
4575
+ "diff", "ps", "curl"))
4235
4576
  GIT_BOOKKEEPING = frozenset((
4236
4577
  "status", "log", "diff", "show", "add", "commit", "push", "fetch", "branch",
4237
4578
  "tag", "remote", "rev-parse", "describe", "shortlog", "blame", "ls-files"))
4579
+ # The git subcommands that only read: a turn made of these and READ_ONLY_PROGRAMS
4580
+ # owes no check at all (`_stop_block`, S4 finding 3). A command not on the lists
4581
+ # is work - an unknown program owes the check, a known one never excuses a write.
4582
+ GIT_READS = GIT_BOOKKEEPING - {"add", "commit", "push", "fetch", "branch", "tag",
4583
+ "remote"}
4584
+ # ssh's options that take the next word, so the host is read after them
4585
+ SSH_VALUE_OPTS = frozenset(("-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J",
4586
+ "-L", "-l", "-m", "-O", "-o", "-p", "-Q", "-R", "-S",
4587
+ "-W", "-w"))
4588
+ # sqlite3 arguments that write a file or run a script even on a read-only open:
4589
+ # a dot-command that writes, runs or reads one, an init script, a second
4590
+ # database, a vacuum into a file
4591
+ SQLITE_WRITE = re.compile(r"^\s*\.(?:output|once|shell|system|save|backup|import|"
4592
+ r"excel|clone|log|trace|cd|open|read)\b|^-{1,2}init\b|"
4593
+ r"\battach\b|\bvacuum\b", re.I | re.M)
4594
+ # sqlite3's options that take the next word, so the database is read after them
4595
+ SQLITE_VALUE_OPTS = frozenset(("cmd", "init", "separator", "newline", "nullvalue",
4596
+ "vfs", "lookaside", "pagecache", "mmap", "heap",
4597
+ "maxsize", "escape"))
4598
+ # A script argument of sed or awk that writes a file or runs a command: sed's
4599
+ # `w`/`W`/`e` commands and flags, awk's redirect, pipe, system() and getline.
4600
+ SCRIPT_WRITE = {"sed": re.compile(r"(?:^|[;}/\n$\d])\s*[wWe]\b"),
4601
+ "awk": re.compile(r"[>|]|\b(?:system|getline)\b"),
4602
+ "gawk": re.compile(r"[>|]|\b(?:system|getline)\b")}
4603
+ # The read verbs of a program whose subcommand decides what it does
4604
+ SUBCOMMAND_READS = {"gh": (("pr", "view"), ("pr", "list"), ("issue", "view"),
4605
+ ("issue", "list")),
4606
+ "codegraph": (("explore",), ("callers",), ("callees",),
4607
+ ("impact",), ("search",), ("node",), ("files",),
4608
+ ("status",))}
4238
4609
  # The options that make an allowlisted program write a file or run one after
4239
- # all (review F6): git's `--output` and external diff/textconv drivers,
4240
- # ripgrep's `--pre`. A word that starts with one of these is not a read.
4241
- # `tree` and `file` left the list instead of growing one here: tree writes
4242
- # through clustered short options (`-fo out`) and `-R -H`, file through `-C`
4243
- # (review N4).
4244
- WRITING_OPTIONS = {"git": ("--output", "--ext-diff", "--textconv"),
4245
- "rg": ("--pre",)}
4610
+ # all (review F6), as a pattern read at an argument's start: git's `--output`
4611
+ # and external diff/textconv drivers, ripgrep's `--pre`, find's actions, a
4612
+ # short-option cluster holding sort's `-o`, sed's `-i`/`-f`, awk's `-i`/`-f`,
4613
+ # and curl's writes to a file and non-GET requests. `tree` and `file` left the
4614
+ # list instead of growing one here: tree writes through clustered short options
4615
+ # (`-fo out`) and `-R -H`, file through `-C` (review N4).
4616
+ WRITING_OPTIONS = {
4617
+ "git": re.compile(r"--(?:output|ext-diff|textconv)"),
4618
+ "rg": re.compile(r"--pre"),
4619
+ "find": re.compile(r"-(?:delete|exec|execdir|ok|okdir|fprint0?|fprintf|fls)$"),
4620
+ "sort": re.compile(r"-[^-]*o|--output"),
4621
+ "sed": re.compile(r"-[^-]*[if]|--(?:in-place|file)"),
4622
+ "awk": re.compile(r"-[^-]*[if]|--(?:include|file|load)"),
4623
+ "gawk": re.compile(r"-[^-]*[if]|--(?:include|file|load|inplace)"),
4624
+ "curl": re.compile(r"-[^-]*[oOdTFKXcD]|--(?:output|remote-name|data|upload-file|"
4625
+ r"form|json|request|config|cookie-jar|dump-header|trace|"
4626
+ r"stderr|libcurl|etag-save|hsts|alt-svc|create-dirs)")}
4246
4627
  # git global options that set configuration or the program path, and so can
4247
4628
  # name a program a read subcommand then runs (`git -c diff.external=./x diff`)
4248
4629
  GIT_CONFIG_OPTS = ("-c", "--config-env", "--exec-path")
@@ -4324,12 +4705,18 @@ def _shell_read(cmd):
4324
4705
  return ">" in text or "<(" in text, "".join(out).replace("<", " ")
4325
4706
 
4326
4707
 
4327
- def _bookkeeping_command(cmd):
4328
- """True when every simple command of `cmd` reads, or does VCS bookkeeping
4329
- that changes no working-tree file. A redirect, a substitution, an env
4330
- assignment in front of a program, a git config override and an option that
4331
- makes a reader write (`WRITING_OPTIONS`) are never bookkeeping: each can
4332
- write or run something this reader does not follow.
4708
+ def _bookkeeping_command(cmd, git=GIT_BOOKKEEPING):
4709
+ """True when every simple command of `cmd` reads, or runs a git subcommand
4710
+ in `git` (VCS bookkeeping that changes no working-tree file by default,
4711
+ GIT_READS for a pure read). A redirect, a substitution, an env assignment
4712
+ in front of a program, a git config override and an option that makes a
4713
+ reader write (`WRITING_OPTIONS`) are never bookkeeping: each can write or
4714
+ run something this reader does not follow. `sed` and `awk` read with no
4715
+ writing script (`SCRIPT_WRITE`), `uniq` with one operand, `gh` and
4716
+ `codegraph` with a read verb (`SUBCOMMAND_READS`). `sqlite3` reads with a
4717
+ read-only open (the option, or `mode=ro` in the database URI) and no
4718
+ writing dot-command or stdin (`SQLITE_WRITE`), `ssh` with a remote command
4719
+ that is itself one of these reads.
4333
4720
 
4334
4721
  ponytail: `git commit` and `git push` run the repository's hooks, and a
4335
4722
  pre-commit autofixer can rewrite files the row never names. They stay on
@@ -4351,10 +4738,45 @@ def _bookkeeping_command(cmd):
4351
4738
  if "/" in words[i]:
4352
4739
  return False
4353
4740
  program, args = words[i], words[i + 1:]
4354
- if any(a.startswith(WRITING_OPTIONS.get(program, ("\0",))) for a in args):
4741
+ writes = WRITING_OPTIONS.get(program)
4742
+ if writes and any(writes.match(a) for a in args):
4355
4743
  return False
4356
4744
  if program in READ_ONLY_PROGRAMS:
4357
4745
  continue
4746
+ plain = [a for a in args if not a.startswith("-")]
4747
+ if program in SCRIPT_WRITE:
4748
+ if any(SCRIPT_WRITE[program].search(a) for a in plain):
4749
+ return False
4750
+ continue
4751
+ if program == "uniq":
4752
+ # `uniq IN OUT` writes its second operand
4753
+ if len(plain) > 1:
4754
+ return False
4755
+ continue
4756
+ if program in SUBCOMMAND_READS:
4757
+ if not any(tuple(plain[:len(verb)]) == verb
4758
+ for verb in SUBCOMMAND_READS[program]):
4759
+ return False
4760
+ continue
4761
+ if program == "sqlite3":
4762
+ j, ro = 0, False
4763
+ while j < len(args) and args[j].startswith("-"):
4764
+ ro = ro or args[j].lstrip("-") == "readonly"
4765
+ j += 2 if args[j].lstrip("-") in SQLITE_VALUE_OPTS else 1
4766
+ db = args[j] if j < len(args) else ""
4767
+ # read-only from the option or the database URI itself, never
4768
+ # from words in the SQL
4769
+ ro = ro or (db.startswith("file:") and "mode=ro" in db.partition("?")[2])
4770
+ if "<" in raw or not ro or any(SQLITE_WRITE.search(a) for a in args):
4771
+ return False
4772
+ continue
4773
+ if program == "ssh":
4774
+ j = 0
4775
+ while j < len(args) and args[j].startswith("-"):
4776
+ j += 2 if args[j][:2] in SSH_VALUE_OPTS and len(args[j]) == 2 else 1
4777
+ if j + 1 >= len(args) or not _bookkeeping_command(" ".join(args[j + 1:]), git):
4778
+ return False
4779
+ continue
4358
4780
  if program != "git":
4359
4781
  return False
4360
4782
  j = 0
@@ -4362,14 +4784,15 @@ def _bookkeeping_command(cmd):
4362
4784
  if args[j].startswith(GIT_CONFIG_OPTS):
4363
4785
  return False
4364
4786
  j += 2 if args[j] in GIT_VALUE_OPTS else 1
4365
- if j >= len(args) or args[j].lower() not in GIT_BOOKKEEPING:
4787
+ if j >= len(args) or args[j].lower() not in git:
4366
4788
  return False
4367
4789
  return bool(segs)
4368
4790
 
4369
4791
 
4370
- def _bookkeeping_turn(rows):
4371
- """True when every work row in `rows` is a shell call that read or did VCS
4372
- bookkeeping and was not seen to change a file.
4792
+ def _bookkeeping_turn(rows, git=GIT_BOOKKEEPING):
4793
+ """True when every work row in `rows` is a shell call that read or ran a
4794
+ git subcommand in `git` (`_bookkeeping_command`) and was not seen to
4795
+ change a file.
4373
4796
 
4374
4797
  A detail of DETAIL_MAX characters or more is never bookkeeping: the ledger
4375
4798
  cut it there, and the command after the cut is unread (review F1). Nor is
@@ -4380,7 +4803,7 @@ def _bookkeeping_turn(rows):
4380
4803
  r.get("kind") == "run" and not _change_row(r)
4381
4804
  and len(str(r.get("detail") or "")) < DETAIL_MAX
4382
4805
  and "[redacted:" not in str(r.get("detail") or "")
4383
- and _bookkeeping_command(r.get("detail")) for r in steps)
4806
+ and _bookkeeping_command(r.get("detail"), git) for r in steps)
4384
4807
 
4385
4808
 
4386
4809
  def _settled(rows):
@@ -4405,13 +4828,14 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4405
4828
  passed before the newest write that changed the tree, or a turn that
4406
4829
  recorded a step and has no passing check - that last half is the
4407
4830
  evidence-shaped trigger, so the same unfounded state stated as a plain
4408
- description is refused too. An explicit 'doğrulanmadı' clears the claim
4409
- branches, so honest uncertainty is always allowed.
4831
+ description is refused too. An explicit 'doğrulanmadı' clears a class whose
4832
+ repair is a check, and only after a check that ran and failed since the
4833
+ newest change (`_admitted`, plan 080): the word alone ended every turn.
4410
4834
 
4411
4835
  The shape is judged before any evidence is read: it is a rule about how the
4412
4836
  reply is written, so it holds for a turn with no work in it too, has no
4413
- repair that depends on the ledger, and is not cleared by the admission
4414
- below - "doğrulanmadı" excuses an unverified claim, not a sign-off.
4837
+ repair that depends on the ledger, and is not cleared by the admission -
4838
+ "doğrulanmadı" excuses an unverified claim, not a sign-off.
4415
4839
 
4416
4840
  Branch order is the reason classes' contract: a new branch goes after the
4417
4841
  ones it overlaps, so it cannot swallow their class - the partial-failure
@@ -4427,12 +4851,21 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4427
4851
  above it carries the one exemption - an advice-only turn is exactly the
4428
4852
  shape that stated both of the claims this class was written for.
4429
4853
 
4854
+ `residual work` (plan 080) is asked after all of them, at every exit that
4855
+ would end the turn (`_residual_block`): a turn that did work and ends on a
4856
+ list of what is left, with an item no blocker accounts for. It never takes
4857
+ a turn another class refused. `stopped slice` (plan 081) is asked at the
4858
+ same exits after it: a slice this session spawned that the journal holds as
4859
+ stopped, worked turn or not.
4860
+
4430
4861
  "Evidence tampered" is the first evidence class: a `ledger_damage` row in
4431
4862
  the turn (`_parse`) means a line of the ledger is not a row, and no fold
4432
4863
  over it can carry a claim - corrupting a `verify_fail` row would otherwise
4433
4864
  turn `check failed` into an allow. It sits after the shape check (a rule
4434
- about the reply, not the ledger) and after NEGATED: an admission that the
4435
- work is unverified claims nothing the damage could carry.
4865
+ about the reply, not the ledger) and before the admission: a done or tested
4866
+ claim on a damaged ledger is refused before the admission is read, since
4867
+ that admission now rests on the ledger's own rows (plan 080). A reply with
4868
+ no claim word is not this class's, damaged ledger or not.
4436
4869
 
4437
4870
  `rows` is this turn's own rows, read once by `stop_reason`: the fold is
4438
4871
  scoped the way `_partial_state`'s already was, and the Stop path still reads
@@ -4447,7 +4880,7 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4447
4880
  if os.environ.get("TEZGAH_STOPSPEC_STRICT"):
4448
4881
  import tezgah_stopspec
4449
4882
  tezgah_stopspec.check(verdict[0], text, session_id, rows, cwd, shape)
4450
- return verdict
4883
+ return (verdict[0], verdict[1] and _said(verdict[1]))
4451
4884
  t = str(text or "")
4452
4885
  shaped = _shape_block(t, cwd) if shape else (None, None)
4453
4886
  if shaped[0]:
@@ -4455,19 +4888,19 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4455
4888
  # the raw vocabulary, not `claims`: what a damaged ledger could carry is any
4456
4889
  # claim word, an admitted conflict included (plan 067 leaves verdicts alone)
4457
4890
  done, verified = bool(DONE.search(t)), bool(VERIFIED.search(t))
4458
- if NEGATED.search(t):
4459
- return (None, None)
4460
4891
  rows = events(session_id) if rows is None else rows
4461
4892
  if (done or verified) and any(r.get("kind") == DAMAGE_KIND for r in rows):
4462
4893
  return ("evidence tampered", TAMPERED)
4463
4894
  if (done or verified) and any(r.get(ORPHAN) for r in rows):
4464
4895
  return ("evidence tampered", ORPHANED)
4896
+ fold = _admitted(fold, t)
4465
4897
  # The trigger is the turn's own evidence, not its words. The claim vocabulary
4466
4898
  # below catches a claim-shaped reply; it missed the same unfounded state
4467
4899
  # stated as a description ("the parser is wired up now"), which is what E2
4468
4900
  # measured at 0 refused of 10 replies. A turn that recorded a step - an edit,
4469
4901
  # a command, a check - and never saw a check pass is refused whatever it
4470
- # says, and the reply's own "doğrulanmadı" (above) is the only exemption.
4902
+ # says; the reply's own "doğrulanmadı" clears it only after a check that ran
4903
+ # and failed since the newest change (`_admitted`, above).
4471
4904
  # `run`/`verify_fail` are steps here for the same reason: work the turn did
4472
4905
  # and left unverified is exactly the state this refuses.
4473
4906
  # `interrupted` too: the call was stopped, so the turn did that much work and
@@ -4480,7 +4913,13 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4480
4913
  if not worked and any(_prose_edit(r) for r in rows) and (
4481
4914
  verified or any(TESTED_WORD.search(m.group(0)) for m in DONE.finditer(t))):
4482
4915
  worked = {"edit"}
4483
- if not worked:
4916
+ # A turn whose steps are only reads - every simple command a known reader
4917
+ # or a git read (`_bookkeeping_turn` with GIT_READS), no change row - is
4918
+ # judged like a turn that did no work (S4 finding 3): a `git status`, a
4919
+ # `tail` or a read-only query owes no check. Its claim words are read the
4920
+ # same way, and a claim is judged against the session's earlier turns below.
4921
+ quiet = _bookkeeping_turn(rows, GIT_READS)
4922
+ if not worked or quiet:
4484
4923
  # With no work, the words are the whole trigger, so a question or a
4485
4924
  # negation is not read as the claim it names (`asserted_claims`).
4486
4925
  done, verified = asserted_claims(t)
@@ -4504,10 +4943,12 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4504
4943
  # exit below - "nothing was done and nothing was claimed" - is exactly the
4505
4944
  # shape that used to leave such a claim unjudged.
4506
4945
  external = _external_claim(t)
4507
- if not worked and not (done or verified) and not external:
4508
- return (None, None)
4946
+ if (not worked or quiet) and not (done or verified) and not external:
4947
+ return _residual_block(t, rows, shape, session_id=session_id, cwd=cwd)
4509
4948
  if lost:
4510
- return fold(rows, worked | {BEGAN_KIND}, external)
4949
+ return _residual_block(t, rows, shape,
4950
+ fold(rows, worked | {BEGAN_KIND}, external),
4951
+ session_id, cwd)
4511
4952
  # Two shapes the turn's own rows cannot judge, so they are judged against
4512
4953
  # the session's (the contract says "no successful check recorded in the
4513
4954
  # session"). Read only for these two, so every other turn still pays for
@@ -4530,8 +4971,10 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4530
4971
  # the turn fold below, which asks for a fresh check.
4531
4972
  srows = events(session_id)
4532
4973
  if not external and _settled(srows):
4533
- return fold(_before_turn(srows), True, None, "this session")
4534
- elif not worked and (done or verified):
4974
+ return _residual_block(t, rows, shape, fold(
4975
+ _before_turn(srows), True, None, "this session"),
4976
+ session_id, cwd)
4977
+ if (not worked or quiet) and (done or verified):
4535
4978
  # A claim in a turn that did nothing: "Tamamlandı, tüm testler
4536
4979
  # geçti" after a turn that edited and ended "doğrulanmadı" was
4537
4980
  # allowed on all five Stop hosts (audit INT-01 / M-2).
@@ -4541,7 +4984,15 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4541
4984
  "this session")
4542
4985
  if refused[0]:
4543
4986
  return refused
4544
- return fold(rows, worked, external)
4987
+ judged = rows
4988
+ if quiet:
4989
+ # the fold reads work off the rows too (the spec's `W` atom), so a turn
4990
+ # of reads hands it none - only an external read, the one thing such a
4991
+ # turn can prove
4992
+ judged, worked = [r for r in rows if not _work_row(r) or (
4993
+ external and _external_read_row([r]) == 0)], set()
4994
+ return _residual_block(t, rows, shape, fold(judged, worked, external),
4995
+ session_id, cwd)
4545
4996
 
4546
4997
 
4547
4998
  def _before_turn(rows):
@@ -4578,7 +5029,8 @@ def _evidence_block(rows, worked, external, where="this turn"):
4578
5029
  return ("check failed",
4579
5030
  "A check failed in %s and the reply claims success. "
4580
5031
  "Report the failure with its exact error line, or fix it and "
4581
- "re-run; do not describe a failed check as passing." % where)
5032
+ "re-run; do not describe a failed check as passing. %s."
5033
+ % (where, ADMISSION))
4582
5034
  # a failure the turn never resolved: the newest check is not a pass, so an
4583
5035
  # earlier green run over a different command does not license the claim
4584
5036
  state = _partial_state(rows)
@@ -4588,15 +5040,16 @@ def _evidence_block(rows, worked, external, where="this turn"):
4588
5040
  "an earlier green run does not cover it. Report that failure "
4589
5041
  "with its exact error line, or fix it and re-run the check; do "
4590
5042
  "not describe a partial result as done. If you are deliberately "
4591
- "stopping on the failure, say what is still broken and mark the "
4592
- "claim \"doğrulanmadı\"."
5043
+ "stopping on the failure, say what is still broken; %s."
4593
5044
  % (_failed_check(rows),
4594
- "after %s's edits" % where if state["edited"] else "in " + where))
5045
+ "after %s's edits" % where if state["edited"] else "in " + where,
5046
+ ADMISSION))
4595
5047
  # A passing check licenses the claim only when it is newer than the newest
4596
5048
  # write the gate saw change the tree: a green run over the previous revision
4597
5049
  # is not evidence about this one, and the ledger already carries both sides
4598
- # (the check's position, and `changed` on the write). The escape is the
4599
- # reply's own "doğrulanmadı", which returns above. The pass has to be one
5050
+ # (the check's position, and `changed` on the write). The admission is the
5051
+ # reply's own "doğrulanmadı" after a check that failed since that write
5052
+ # (`_admitted`). The pass has to be one
4600
5053
  # run in the newest change's own repository (`_last_pass`'s `repo`).
4601
5054
  last_change = _last_change(rows)
4602
5055
  last_pass = _last_pass(rows, _change_repo(rows, last_change))
@@ -4621,8 +5074,11 @@ def _evidence_block(rows, worked, external, where="this turn"):
4621
5074
  "its output, or read the rendered screen (`analyze-app`: the "
4622
5075
  "accessibility/DOM tree, a screenshot at the widths in scope) "
4623
5076
  "and say what it showed. A green unit suite does not cover "
4624
- "what a person sees; if you are stopping short, mark the "
4625
- "claim \"doğrulanmadı\"." % (where, shown or "a UI file"))
5077
+ "what a person sees, and \"{UNVERIFIED}\" does not stand in for "
5078
+ "the screen; if nothing can render it here, write "
5079
+ "\"{UNVERIFIED}\" and name it under \"{BLOCKERS_HEADING}\" as {ENV} "
5080
+ "with the command that failed."
5081
+ % (where, shown or "a UI file"))
4626
5082
  if ui_write >= 0:
4627
5083
  # The floor, asked of a component turn on top of the screen proof: a
4628
5084
  # screenshot or a rendered check says what the thing looks like and
@@ -4644,7 +5100,9 @@ def _evidence_block(rows, worked, external, where="this turn"):
4644
5100
  "(`tezgah-design derive` writes the contract when the repo "
4645
5101
  "has none; the measurement is the per-component styles and "
4646
5102
  "states the analyze-app loop produces) and report every "
4647
- "violation it prints, or mark the claim \"doğrulanmadı\"."
5103
+ "violation it prints; if the check cannot run here, write "
5104
+ "\"{UNVERIFIED}\" and name it under \"{BLOCKERS_HEADING}\" as {ENV} "
5105
+ "with the command that failed."
4648
5106
  % (where, shown or "a component file"))
4649
5107
  # A screen proof stands where a unit pass stands for the rest of this fold,
4650
5108
  # so a UI turn whose UI work is proven is not asked for a check it already
@@ -4670,12 +5128,12 @@ def _evidence_block(rows, worked, external, where="this turn"):
4670
5128
  return ("stale evidence",
4671
5129
  "Stale evidence: the newest check that passed ran before %s %s "
4672
5130
  "written, so it verified an earlier revision of the tree than the "
4673
- "one this reply is about. Re-run the smallest check that covers "
4674
- "the files written since (their own tests, not the whole suite) "
4675
- "and report its output, or mark the claim \"doğrulanmadı\". "
5131
+ "one this reply is about. Run the tests of the files written since "
5132
+ "(`python3 tests/impacted.py --run <paths>` where the repo has "
5133
+ "it; not the whole suite) and report its output; %s. "
4676
5134
  "A green run over the previous revision does not cover this one."
4677
5135
  % (shown or "a file this session wrote",
4678
- "was" if len(names) <= 1 else "were"))
5136
+ "was" if len(names) <= 1 else "were", ADMISSION))
4679
5137
  elif worked and _last_pass(rows) > last_change:
4680
5138
  # a pass newer than the change exists, only in another repository: the
4681
5139
  # class stays (ledger continuity), the text names the real reason
@@ -4683,34 +5141,39 @@ def _evidence_block(rows, worked, external, where="this turn"):
4683
5141
  "%s changed %s, and the check that passed after it ran in "
4684
5142
  "another repository (%s), so it says nothing about this one. "
4685
5143
  "Run the check in the repository you changed and report its "
4686
- "output, or mark the claim \"doğrulanmadı\"."
5144
+ "output; %s."
4687
5145
  % (where.capitalize(), _change_repo(rows, last_change),
4688
5146
  rows[max(i for i, r in enumerate(rows)
4689
- if passing_check(r))].get("repo")))
5147
+ if passing_check(r))].get("repo"), ADMISSION))
4690
5148
  elif worked:
4691
5149
  return ("no verify_ok",
4692
5150
  "%s did work (edits or commands) and no check ran "
4693
5151
  "successfully in it (nothing recorded as verify_ok with a "
4694
5152
  "real result and an unmasked command), so nothing here supports "
4695
- "calling it done, complete or verified. Run the smallest check "
4696
- "that proves the change (the tests of what changed, not the "
4697
- "whole suite) and report its output, or mark the claim "
4698
- "\"doğrulanmadı\". Do not "
5153
+ "calling it done, complete or verified. Run the tests of the "
5154
+ "files written (`python3 tests/impacted.py --run <paths>` where "
5155
+ "the repo has it; not the whole suite) and report its output; "
5156
+ "%s. Do not "
4699
5157
  "describe a check you did not run as if it ran."
4700
- % where.capitalize())
5158
+ % (where.capitalize(), ADMISSION))
4701
5159
  # Last, so none of the classes above loses its turn to it: every shape this
4702
5160
  # one refuses is a shape the fold was about to allow. That is the whole
4703
5161
  # ordering rule of this function - the branch is asked only where the answer
4704
5162
  # would otherwise be "this turn may end".
4705
5163
  if unread:
4706
- return ("no external read",
4707
- "The reply states the state of a system tezgah does not own (%s) "
4708
- "and no read of that system ran in %s. Only the system "
4709
- "itself knows: a local client's cache, a stale checkout and a "
4710
- "memory of what used to be published all answer this wrongly. Run "
4711
- "the read and report its output - %s - or mark the claim "
4712
- "\"doğrulanmadı\"." % (cut(external.strip(), 80), where,
4713
- _external_command(external)))
5164
+ return ("no external read", _external_text(external, where))
5165
+ return (None, None)
5166
+
5167
+
5168
+ def _external_text(external, where="this turn"):
5169
+ """The `no external read` refusal text, for the fold and for `_admitted`."""
5170
+ return ("The reply states the state of a system tezgah does not own (%s) "
5171
+ "and no read of that system ran in %s. Only the system "
5172
+ "itself knows: a local client's cache, a stale checkout and a "
5173
+ "memory of what used to be published all answer this wrongly. Run "
5174
+ "the read and report its output - %s; a read that fails is an "
5175
+ "answer too, quote it." % (cut(external.strip(), 80), where,
5176
+ _external_command(external)))
4714
5177
  return (None, None)
4715
5178
 
4716
5179
 
@@ -4748,18 +5211,13 @@ EXTERNAL_SYSTEM = re.compile(
4748
5211
  r"\b(?:npm|yarn|pnpm|pip|pypi|py ?pi|crates(?:\.io)?|rubygems|gem|"
4749
5212
  r"brew|homebrew|formula|tap|dist-tag|tarball|registry|releases?|tags?|"
4750
5213
  r"workflow|pipeline|ci|github)\b", re.I)
4751
- EXTERNAL_STATE = re.compile(
4752
- r"\b(?:missing|absent|unpublished|exists?|not (?:published|released|found|"
4753
- r"there|listed|present)|published|released|deprecated|outdated|stale|"
4754
- r"yok|eksik|yayımlan|yayınlan)\b", re.I)
5214
+ EXTERNAL_STATE = re.compile(r"\b(?:%s)\b" % alts("external_state"), re.I)
4755
5215
  # The status of a run, which pairs with a CI subject alone: "the workflow is red"
4756
5216
  # and "CI passed" are claims about a system tezgah does not own, while the same
4757
5217
  # words beside `release` are prose ("Suite green, release ready"), which is what
4758
5218
  # the split is for.
4759
5219
  EXTERNAL_CI = re.compile(r"\b(?:workflow|pipeline|ci|github)\b", re.I)
4760
- EXTERNAL_CI_STATE = re.compile(
4761
- r"\b(?:green|red|passing|passes|passed|failing|fails|failed|behind|flaky|"
4762
- r"geçti|başarısız)\b", re.I)
5220
+ EXTERNAL_CI_STATE = re.compile(r"\b(?:%s)\b" % alts("external_ci_state"), re.I)
4763
5221
  # How far apart, on one line, the two halves of a claim may sit. 45 characters
4764
5222
  # holds every form the incident took ("npm 0.22.0 yayımlanmadı" is 12 apart,
4765
5223
  # "the registry has no such version published" is 30) and drops the long report
@@ -4771,10 +5229,8 @@ EXTERNAL_GAP = 45
4771
5229
  # numbers in prose. A two-part number was the single largest source of false
4772
5230
  # positives in the measurement above.
4773
5231
  EXTERNAL_VERSION = re.compile(
4774
- r"\bv\d+\.\d+\.\d+[^\n]{0,40}?\b(?:missing|absent|unpublished|exists?|"
4775
- r"published|released|yok|eksik|yayımlan|yayınlan)\b"
4776
- r"|\b(?:missing|absent|unpublished|exists?|published|released|yok|eksik|"
4777
- r"yayımlan|yayınlan)\b[^\n]{0,40}?v\d+\.\d+\.\d+", re.I)
5232
+ r"\bv\d+\.\d+\.\d+[^\n]{{0,40}}?\b(?:{0})\b|\b(?:{0})\b[^\n]{{0,40}}?v\d+\.\d+\.\d+"
5233
+ .format(alts("publish_state")), re.I)
4778
5234
  # The read that answers it, at a command position on the masked text - the
4779
5235
  # convention `UI_CHECK` and `NETWORK_READ` follow - so a reply that merely names
4780
5236
  # `npm view`, and the search whose pattern is that name, are not reads of
@@ -4886,6 +5342,833 @@ def _external_read_row(rows):
4886
5342
  return -1
4887
5343
 
4888
5344
 
5345
+ def _failed_since_change(rows):
5346
+ """True when a check ran and failed after the newest write that changed the
5347
+ tree (`_last_change`): the attempt the admission rests on. A failure before
5348
+ that write is about the tree the write replaced."""
5349
+ return any(r.get("kind") == "verify_fail" for r in rows[_last_change(rows) + 1:])
5350
+
5351
+
5352
+ def _proof_attempt_failed(rows):
5353
+ """True when the proof `no ui_ok` asks of `rows` was attempted and failed
5354
+ after the write it is about: a check that sees the screen (`UI_CHECK`) after
5355
+ the newest UI write - the design check measures that rendered screen, so a
5356
+ screen that did not render leaves it nothing to run on - or, with the screen
5357
+ proven, the design check (`DESIGN_CHECK`) after the newest component write.
5358
+ A failed screen check is the attempt the admission rests on there; a failed
5359
+ unit check is not."""
5360
+ def failed_after(i, check):
5361
+ return any(r.get("kind") == "verify_fail"
5362
+ and check.search(mask(str(r.get("detail") or "")))
5363
+ for r in rows[i + 1:])
5364
+ write, proof = _ui_evidence(rows)
5365
+ if write >= 0 and proof <= write:
5366
+ return failed_after(write, UI_CHECK)
5367
+ return failed_after(_design_evidence(rows)[0], DESIGN_CHECK)
5368
+
5369
+
5370
+ def _prose(text):
5371
+ """`text` with its fenced blocks and inline code blanked: a test selector
5372
+ (`pytest -k unverified`), a file name (`DECISION.md`) or a pasted log line is
5373
+ no word of the reply's own."""
5374
+ return CODE_SPAN.sub(" ", FENCED_BODY.sub(" ", str(text or "")))
5375
+
5376
+
5377
+ def _admitted(fold, text):
5378
+ """`fold` with the admission applied (plan 080). A verdict in
5379
+ ADMITTED_CLASSES is cleared when the reply's prose admits the work is
5380
+ unverified (NEGATED) and the rows that fold judged hold a check that failed
5381
+ since their newest change. The failure is the part admitted, and nothing
5382
+ else: the fold is asked again without the failed checks, so a screen or
5383
+ design proof the turn owes still comes back (`no ui_ok`) unless that proof
5384
+ is the check that ran and failed (`_proof_attempt_failed`), and an external
5385
+ claim with no read of its system still owes the read (`no external read`) -
5386
+ otherwise a failed unit check would end a turn its passing twin cannot.
5387
+ Wrapped around whichever fold the selector was given, so the live rule and
5388
+ the temporal spec read the admission the same way."""
5389
+ if not NEGATED.search(_prose(text)):
5390
+ return fold
5391
+
5392
+ def admitted(rows, worked, external, *where):
5393
+ verdict = fold(rows, worked, external, *where)
5394
+ if not verdict[0] or not _failed_since_change(rows):
5395
+ return verdict
5396
+ owed = fold([r for r in rows if r.get("kind") != "verify_fail"],
5397
+ worked, external, *where)
5398
+ if owed[0] and owed[0] not in ADMITTED_CLASSES and not (
5399
+ owed[0] == "no ui_ok" and _proof_attempt_failed(rows)):
5400
+ return owed
5401
+ if external is not None and _external_read_row(rows) < 0:
5402
+ return ("no external read", _external_text(external, *where))
5403
+ return (None, None)
5404
+ return admitted
5405
+
5406
+
5407
+ # The eleventh Stop class, `residual work` (plan 080): a turn that did work and
5408
+ # ends on a list of what is left - "Açık kalanlar", "Doğrulanmadı:", "Sıradaki
5409
+ # adım" - instead of doing it. Before the class, 503 of 1876 final replies in
5410
+ # the owner's omp and Claude transcripts carried such a label at a line start,
5411
+ # 467 of them in a turn that called a non-read tool: a section that reported
5412
+ # the work was the cheapest end of a turn. An item stays only when something
5413
+ # accounts for it: a KARAR or GERİ ALINAMAZ token (a choice or a consent, which
5414
+ # no ledger row can prove), or a YETKİ or ORTAM token together with a quote of a
5415
+ # command that failed in the turn - the type, and the attempt and its error the
5416
+ # request asks for; either alone keeps nothing, so an untyped "Doğrulanmadı:
5417
+ # `pytest` ..." is refused like any other item. Asked only in
5418
+ # a turn that did work - a spec, an answer or a plan proposal lists
5419
+ # assumptions, non-goals and next steps as its content - and only at an exit
5420
+ # that would end the turn, so it never takes a turn another class refused.
5421
+ # Labels the other rules ask for stay out of the list: a spec's assumptions and
5422
+ # alternatives, a measurement's limits ("Ölçüm sınırı"), risks ("Riskler",
5423
+ # "Regresyon riski": the reply keeps its risks) and a bare recommendation
5424
+ # ("Önerim"); suggested improvements are work left and stay in. ponytail: a word
5425
+ # list read at line starts, not a reader of what an item means - a doable item
5426
+ # typed KARAR passes, which the question endings `tezgah-gate replay` counts are
5427
+ # there to watch.
5428
+ # The first-person negatives ("Yapamadım", "Bakmadıklarım", "Test etmedim"),
5429
+ # the passive stems "kontrol edil-", "incelen-", "ölçül-", "çözül-", "denen-" and
5430
+ # the English forms after them were added from the S4 corpus (finding 4).
5431
+ RESIDUAL_LABEL = re.compile(r"(?:%s)" % alts("residual_label"))
5432
+ # "Kalan" is also an adjective ("Kalan riskler", "Kalan süre", "Ayakta kalan
5433
+ # tek şey"): it opens a list only as the label's first word and on the work it
5434
+ # names - "Kalan:", "Kalanlar", "Kalan iş", "Kalan adımlar", "Kalan tek şey".
5435
+ RESIDUAL_FIRST = alts("residual_first")
5436
+ # At most three words may lead any other stem: "Bu turda açık kalanlar", "Henüz
5437
+ # yapılmayanlar", "Bu turda henüz bakılmayanlar".
5438
+ RESIDUAL_AT = re.compile(r"(?:%s)|(?:[^\W\d][\w'’-]*\s+){0,3}(?:%s)"
5439
+ % (RESIDUAL_FIRST, RESIDUAL_LABEL.pattern))
5440
+ # A label whose own words close the list: a claim word (`asserted_claims`), or
5441
+ # a completion verb or participle the claim vocabulary does not carry ("Eksik
5442
+ # kapandı", "Düzeltilen eksik", "Doğrulanmayan kalmadı", "Eksik olan kanıt artık
5443
+ # var"). "Kısmen ..." stays open.
5444
+ LABEL_DONE = re.compile(r"\b(?:%s)\b" % alts("label_done"))
5445
+ PARTIAL = re.compile(r"(?:%s)\b" % alts("partial"))
5446
+ # A short label that says nothing is left - it ends on "yok"/"none" or opens on
5447
+ # "no"/"none" - is no list ("Eksik yok"); in a longer one, or after a comma, the
5448
+ # "yok" is about something else ("Doğrulanmadı, kanıt yok"). A word in a
5449
+ # parenthesis is a comment on the label.
5450
+ NOTHING_LEFT = re.compile(r"(?:%s)\W*$|^(?:%s)\b"
5451
+ % (alts("nothing_left"), alts("nothing_lead")))
5452
+ NOTHING_SHORT = 4
5453
+ PARENTHESIS = re.compile(r"\([^)]*\)")
5454
+ # An item that is only that word, or opens on it before a stop ("yok, iş
5455
+ # bitti."), or is a bare zero, is no item; a "yok" inside an item ("playwright
5456
+ # yok") is content and is judged like the rest.
5457
+ EMPTY_ITEM = re.compile(r"\W*(?:(?:%s)(?:\s*[.,;:!—–-][^\n]*)?|0)\W*"
5458
+ % alts("empty_item"), re.I)
5459
+ # The blocker tokens, as the capitalised token the contract names them by, in
5460
+ # every pack's spelling (`blockers.<kind>.pattern`: DECISION and KARAR alike), a
5461
+ # suffix allowed ("KARARI senin"); a token negated by the next word ("KARAR
5462
+ # gerekmiyor") is none, and code is blanked first (`_prose`).
5463
+ # BLOCKER_SAID needs no ledger row: a choice or a consent leaves none to check.
5464
+ # BLOCKER_TRIED is the attempt that failed - it counts only beside the error
5465
+ # its type names (ENV_ERROR for the env token, ACCESS_ERROR for the access one,
5466
+ # read off the match's group name) and its ledger evidence: a quote of a
5467
+ # command that failed in the turn (`_quotes_failure`), or a spawn call of the
5468
+ # turn the host answered with a provider failure (`_spawn_failed`: a 429, a
5469
+ # quota, a credential).
5470
+
5471
+
5472
+ def _tokens(kind):
5473
+ """Every pack's spelling of blocker `kind`, as one alternation."""
5474
+ return "|".join(pack_values("blockers", kind, "pattern")) or "(?!)"
5475
+
5476
+
5477
+ BLOCKER_NOT = r"(?!\s+(?:%s))" % alts("blockers", "said_not")
5478
+ BLOCKER_SAID = re.compile(r"\b(?:%s|%s)[A-ZÇĞİIÖŞÜ]*\b"
5479
+ % (_tokens("decision"), _tokens("irreversible")) + BLOCKER_NOT)
5480
+ # BLOCKER_SAID's consent half: a GERİ ALINAMAZ item needs no options.
5481
+ IRREVERSIBLE_SAID = re.compile(BLOCKER_SAID.pattern.replace(_tokens("decision") + "|", "", 1))
5482
+ # After the access or env token, "yok" and "not" describe the blocker ("YETKİ
5483
+ # yok", "ENV not set"), so only a pack's `tried_not` words negate them; the
5484
+ # suffix is read on the stems a pack spells with one, so ACCESSIBILITY or
5485
+ # ENVELOPE is no type, while the plural ENVIRONMENTS is.
5486
+ BLOCKER_TRIED = re.compile(r"(?:\b(?P<access>%s)|\b(?P<env>%s))\b(?!\s+(?:%s))"
5487
+ % (_tokens("access"), _tokens("env"),
5488
+ alts("blockers", "tried_not")))
5489
+ # The word after the token that says the grant or tool is absent ("YETKİ yok").
5490
+ BLOCKER_ABSENT = r"(?:\s+(?:%s)\b)?" % alts("blockers", "absent")
5491
+ ENV_ERROR = re.compile(alts("env_error"), re.I)
5492
+ ACCESS_ERROR = re.compile(USER_ERROR.pattern + "|" + alts("access_error"), re.I)
5493
+ # A spawn call's failure classes that are the provider's, not the brief's: a
5494
+ # rate limit or a timeout (`transient`) and a quota or a credential (`user`).
5495
+ SPAWN_FAIL_CLASSES = frozenset(("transient", "user"))
5496
+ # The words that tie an item to a spawn call: only such an item borrows a
5497
+ # failed spawn's row as its evidence (`_tried_holds`).
5498
+ SPAWN_WORDS = re.compile(alts("spawn_words"))
5499
+ # A search that matched nothing exits 1 and failed nothing (`_failed_commands`).
5500
+ NO_MATCH_PROGRAMS = frozenset(("grep", "egrep", "fgrep", "rg", "ag"))
5501
+ LIST_MARK = re.compile(r"\s*(?:\d+[.)]|[-*+])\s+")
5502
+ QUOTE_MARK = re.compile(r"^(\s*)>\s?")
5503
+ LEAD_SYMBOLS = re.compile(r"^[^\w#*_`]+")
5504
+ CODE_SPAN = re.compile(r"`([^`\n]+)`")
5505
+ FENCED_BODY = re.compile(r"^\s*(?:```|~~~)[^\n]*\n(.*?)^\s*(?:```|~~~)", re.M | re.S)
5506
+ COMMAND_SPLIT = re.compile(r"&&|\|\||;|\|")
5507
+ # The refusal leads and blocker tokens are the configured language's
5508
+ # (`{RESIDUAL_LEAD}`, `{DECISION}`..., filled by `_said`): the owner's tr pack
5509
+ # opens in Turkish because Claude Code shows the refusal's first line to the
5510
+ # user; the canonical English pack adds no lead.
5511
+ RESIDUAL_WORK = (
5512
+ "{RESIDUAL_LEAD}The reply lists "
5513
+ "work that is left (%s) at the end of a turn that did work. Do each item "
5514
+ "now with the tools you have: a local merge of a branch this session made "
5515
+ "and the removal of its worktree are such steps. A step that is outward or "
5516
+ "cannot be undone (a push or a release with no grant from the user this "
5517
+ "session, a force push, a delete of what the session did not make) is never "
5518
+ "taken to clear this list - it is {IRREVERSIBLE}, and you ask. An item may "
5519
+ "stay only as one of four "
5520
+ "blockers, named in capitals: {DECISION} (a choice only the user can make, "
5521
+ "with its options and a \"{RECOMMENDED}:\" default - never \"which first\" or "
5522
+ "\"shall I go on\"), {ACCESS} (a credential or grant "
5523
+ "no tool supplies), {IRREVERSIBLE} (needs consent), {ENV} (a tool or "
5524
+ "environment is missing); a {ACCESS} or {ENV} item quotes, in backticks, the "
5525
+ "command that failed in this turn and its error - a quote with no type, "
5526
+ "or a type with no quote, is not one. \"Out of "
5527
+ "scope\", \"later\", \"time\" and \"could be added\" are not blockers. Report "
5528
+ "what is left under one heading, \"{BLOCKERS_HEADING}\", at most three lines: item - "
5529
+ "blocker - evidence - what unblocks it.")
5530
+ # The `stopped slice` class (plan 081): a subagent slice this session spawned
5531
+ # ended stopped - a quota, a 429, an abort, a cut-off - and the turn would end
5532
+ # with its work unresumed. Filled with the slice id, its failure class and the id
5533
+ # twice more.
5534
+ STOPPED_SLICE = (
5535
+ "{SLICE_LEAD}Slice %s, spawned by this session, stopped (%s) and "
5536
+ "its work is not resumed. Resume it with `tezgah-task slice resume %s` - "
5537
+ "the native path first, else the re-dispatch brief it prints - or, if the "
5538
+ "work was finished another way, record that with `tezgah-task slice done "
5539
+ "%s`.")
5540
+
5541
+ # The `ready steps` class (plan 086, S1): the newest todo list (`note_todo`)
5542
+ # holds a pending item, not taken and not blocked, and the reply ends the turn
5543
+ # without it. Filled with the count and up to three heads.
5544
+ READY_STEPS = (
5545
+ "{READY_LEAD}The newest todo list holds %d ready step(s) this "
5546
+ "turn neither did nor typed as a blocker: %s. Dispatch every ready step "
5547
+ "now - independent ones as subagents in one message - and tick each one "
5548
+ "you finish. A step that waits on a running slice or on the user is marked "
5549
+ "blocked in the todo list (omp `block`, naming the slice or the blocker), "
5550
+ "or named in the reply on one line with its {DECISION}, {ACCESS}, {IRREVERSIBLE} "
5551
+ "or {ENV}. End the turn only when no step is ready.")
5552
+
5553
+
5554
+ def _tr_lower(text):
5555
+ """`text` lowercased the Turkish way (I -> ı, İ -> i): `str.lower` reads
5556
+ "DOĞRULANMADI" as "doğrulanmadi" and "İleride" with a combining dot."""
5557
+ return text.replace("I", "ı").replace("İ", "i").lower()
5558
+
5559
+
5560
+ def _label(line):
5561
+ """(label, rest) when `line` opens with a label - a markdown heading, a bold
5562
+ or italic lead (cut at its own colon), or at most six words before a colon -
5563
+ after an optional list marker, quote marker or leading symbol (an emoji),
5564
+ else None. `rest` is what follows the label on its line."""
5565
+ s = QUOTE_MARK.sub("", line).strip()
5566
+ marker = LIST_MARK.match(s)
5567
+ if marker:
5568
+ s = s[marker.end():]
5569
+ s = LEAD_SYMBOLS.sub("", s)
5570
+ m = re.match(r"#{1,6}\s+(.+)$", s)
5571
+ if m:
5572
+ return LEAD_SYMBOLS.sub("", m.group(1)).strip(" *_:"), ""
5573
+ m = re.match(r"(\*\*|__|\*|_)(.+?)\1(.*)$", s)
5574
+ if m:
5575
+ head, colon, inner = m.group(2).partition(":")
5576
+ rest = (inner + " " + m.group(3).strip().lstrip(":")) if colon \
5577
+ else m.group(3).strip().lstrip(":")
5578
+ return head.strip(), rest.strip()
5579
+ head, colon, rest = s.partition(":")
5580
+ if colon and head.strip() and len(head.split()) <= 6 and "`" not in head:
5581
+ return head.strip(), rest.strip()
5582
+ return None
5583
+
5584
+
5585
+ def _residual(label):
5586
+ """True when `label` names work that is left: it opens, after at most two
5587
+ words, with a RESIDUAL_LABEL form (TODO only in capitals, the marker), its
5588
+ own words close nothing ("Eksikler giderildi" is done, "Kısmen güncellendi"
5589
+ is not) and it does not say nothing is left - in a few words, or after its
5590
+ last colon ("Engeller: yok")."""
5591
+ if label.startswith("TODO"):
5592
+ return True
5593
+ for low in (_tr_lower(label), label.lower()):
5594
+ core = " ".join(PARENTHESIS.sub(" ", low).split())
5595
+ _head, colon, after = core.rpartition(":")
5596
+ if not RESIDUAL_AT.match(core) or (colon and EMPTY_ITEM.fullmatch(after)) or (
5597
+ NOTHING_LEFT.search(core) and len(core.split()) <= NOTHING_SHORT
5598
+ and not re.search(r"[,;:—–]", core)):
5599
+ continue
5600
+ if PARTIAL.match(core) or not (any(asserted_claims(core))
5601
+ or LABEL_DONE.search(core)):
5602
+ return True
5603
+ return False
5604
+
5605
+
5606
+ def _bare_label(line):
5607
+ """The text of a plain line that is a label with no colon, else "": at most
5608
+ four words that open on a RESIDUAL_FIRST or RESIDUAL_LABEL form with only a
5609
+ suffix after it ("Açık kalanlar", "Unresolved"). "Pending changes" and
5610
+ "Kod değişikliği yapılmadı" name something else and are no label."""
5611
+ s = LEAD_SYMBOLS.sub("", line.strip()).strip()
5612
+ if not s or ":" in s or len(s.split()) > 4:
5613
+ return ""
5614
+ for low in (_tr_lower(s), s.lower()):
5615
+ core = " ".join(PARENTHESIS.sub(" ", low).split())
5616
+ m = re.match(RESIDUAL_FIRST, core) or RESIDUAL_LABEL.match(core)
5617
+ if m and not re.search(r"\s", core[m.end():]):
5618
+ return s
5619
+ return ""
5620
+
5621
+
5622
+ def _indent(line):
5623
+ return len(line) - len(line.lstrip())
5624
+
5625
+
5626
+ def _sections(text):
5627
+ """[(label, intro, items)] for every residual section, outside fences and
5628
+ tables, a quote marker read as plain text. A list item that opens with the
5629
+ label holds itself alone - its rest and its deeper lines. A markdown heading
5630
+ holds the lines up to the next heading of its level or above, or prose after
5631
+ a blank line once it holds something. Any other label line holds its rest
5632
+ and the lines under it, up to the next heading, the next label at its
5633
+ depth, or that prose. The list entries at the shallowest depth are the
5634
+ items, and what comes before the first is the intro; with no list,
5635
+ everything is one item. A fenced block belongs to the item it follows,
5636
+ list-shaped lines inside it included."""
5637
+ lines = [QUOTE_MARK.sub(r"\1", ln) for ln in str(text or "").split("\n")]
5638
+ out, i, inside = [], 0, False
5639
+ while i < len(lines):
5640
+ line = lines[i]
5641
+ i += 1
5642
+ if FENCE.match(line):
5643
+ inside = not inside
5644
+ continue
5645
+ found = None if inside or TABLE_ROW.match(line) else _label(line)
5646
+ if (not found and not inside and not TABLE_ROW.match(line)
5647
+ and not LIST_MARK.match(line) and i < len(lines)
5648
+ and LIST_MARK.match(lines[i]) and _bare_label(line)):
5649
+ # a label line with no colon right over a list ("Açık kalanlar\n-
5650
+ # ...", S4 finding 4)
5651
+ found = (_bare_label(line), "")
5652
+ if not found or not _residual(found[0]):
5653
+ continue
5654
+ label, rest = found
5655
+ depth, own = _indent(line), bool(LIST_MARK.match(line))
5656
+ level = HEADING.match(line) and len(line.strip()) - len(line.strip().lstrip("#"))
5657
+ body, blank, fenced = [], False, False
5658
+ while i < len(lines):
5659
+ nxt = lines[i]
5660
+ if FENCE.match(nxt):
5661
+ fenced = not fenced
5662
+ elif not fenced:
5663
+ if not nxt.strip():
5664
+ blank = True
5665
+ i += 1
5666
+ continue
5667
+ deeper = _indent(nxt) > depth
5668
+ if own and not deeper:
5669
+ break
5670
+ held = rest or any(b.strip() for b in body)
5671
+ prose_after = (not deeper and not LIST_MARK.match(nxt)
5672
+ and blank and held)
5673
+ head = HEADING.match(nxt) and len(nxt.strip()) - len(nxt.strip().lstrip("#"))
5674
+ if level:
5675
+ if (head and head <= level) or (not head and prose_after):
5676
+ break
5677
+ elif not deeper and (head or prose_after or (
5678
+ not LIST_MARK.match(nxt) and _label(nxt))):
5679
+ break
5680
+ body.append(nxt)
5681
+ i += 1
5682
+ out.append(_items(label, rest, body, own))
5683
+ return out
5684
+
5685
+
5686
+ def _items(label, rest, body, own):
5687
+ """(label, intro, items) for one section's body (`_sections`)."""
5688
+ marks, fenced = [], False
5689
+ for b in body:
5690
+ if FENCE.match(b):
5691
+ fenced = not fenced
5692
+ marks.append(not fenced and not FENCE.match(b) and bool(LIST_MARK.match(b)))
5693
+ top = min((_indent(b) for b, m in zip(body, marks) if m), default=None)
5694
+ pre, entries = [], []
5695
+ for b, m in zip(body, marks):
5696
+ if m and _indent(b) == top:
5697
+ entries.append(b[LIST_MARK.match(b).end():])
5698
+ elif entries:
5699
+ entries[-1] += "\n" + b
5700
+ else:
5701
+ pre.append(b)
5702
+ if entries and not own:
5703
+ return (label, " ".join([rest] + pre).strip(), entries)
5704
+ return (label, "", ["\n".join([rest] + body).strip()])
5705
+
5706
+
5707
+ def residual_sections(text):
5708
+ """[(label, items)] for every residual section of a reply (`_sections`):
5709
+ what the Stop rule's `residual work` class and the replay's per-turn count
5710
+ both read."""
5711
+ return [(label, items) for label, _intro, items in _sections(text)]
5712
+
5713
+
5714
+ def residual_text(text):
5715
+ """The residual sections of `text` written back out - each as a heading,
5716
+ which holds a label of any length and its code, then its intro and one `- `
5717
+ line per item - so a store that keeps only part of a reply (Cursor's head
5718
+ and tail) still hands the Stop rule what the cut would drop. Reads back
5719
+ through `_sections` as the same sections."""
5720
+ return "".join("## %s\n%s%s\n\n" % (label, intro + "\n" if intro else "", "\n".join(
5721
+ "- " + item.replace("\n", "\n ") for item in items))
5722
+ for label, intro, items in _sections(text))
5723
+
5724
+
5725
+ def _quotes_failure(item, failed):
5726
+ """True when a code span or a fenced line of `item` quotes a command that
5727
+ failed: the span is one of its commands, holds one whole (two words at
5728
+ least, so a bare `git` that failed is not `git push`), or opens one word for
5729
+ word (`pytest` for `pytest -q`). A span cut short with `…` or `...` opens
5730
+ one with what is left of it, two words at least (`tezgah-task note 001
5731
+ state …`). A fragment such as `src` of `ls src/e2e` quotes nothing."""
5732
+ spans = CODE_SPAN.findall(item) + [
5733
+ ln for body in FENCED_BODY.findall(item) for ln in body.split("\n")]
5734
+ for span in (re.sub(r"^\$\s+", "", s.strip()) for s in spans):
5735
+ head = re.sub(r"\s*(?:…|\.\.\.)$", "", span)
5736
+ shortened = head != span and len(head.split()) >= 2
5737
+ for cmd in failed:
5738
+ for seg in (s.strip() for s in COMMAND_SPLIT.split(cmd)):
5739
+ if not seg or not span:
5740
+ continue
5741
+ if seg == span or (seg in span and len(seg.split()) >= 2) or (
5742
+ seg.startswith(span) and seg[len(span)].isspace()) or (
5743
+ shortened and seg.startswith(head)):
5744
+ return True
5745
+ return False
5746
+
5747
+
5748
+ def _failed_commands(rows):
5749
+ """The commands that failed in `rows`: a `verify_fail`, or a shell row with
5750
+ exit 1. A write tool's `edit` row is none (its detail is a path), and
5751
+ neither is a search alone that matched nothing (NO_MATCH_PROGRAMS: grep's
5752
+ exit 1 is its "no"); a grep after `&&`, `;` or a pipe reports the line's
5753
+ exit, so that row stays a failure."""
5754
+ out = []
5755
+ for r in rows:
5756
+ if r.get("kind") == "edit" or not (r.get("exit") == 1
5757
+ or r.get("kind") == "verify_fail"):
5758
+ continue
5759
+ cmd = str(r.get("detail") or "").replace(FAILED_MARK, "").strip()
5760
+ segs = [words for words in _shell_segments(cmd) if words]
5761
+ if (r.get("kind") != "verify_fail" and len(segs) == 1
5762
+ and segs[0][0] in NO_MATCH_PROGRAMS):
5763
+ continue
5764
+ out.append(cmd)
5765
+ return out
5766
+
5767
+
5768
+ def _spawn_failed(rows):
5769
+ """True when `rows` hold a spawn call (SUBAGENT_TOOLS) the host answered
5770
+ with a provider failure (SPAWN_FAIL_CLASSES): the ledger's evidence for a
5771
+ YETKİ or ORTAM that no shell command shows."""
5772
+ return any(str(r.get("tool") or "").lower() in SUBAGENT_TOOLS
5773
+ and r.get("fail_class") in SPAWN_FAIL_CLASSES for r in rows)
5774
+
5775
+
5776
+ def _tried_holds(item, said, failed, spawn_failed=False):
5777
+ """True when a YETKİ or ORTAM item carries its evidence: the error its type
5778
+ names (ENV_ERROR for ORTAM/ENV, ACCESS_ERROR for YETKİ/ACCESS), read in the
5779
+ item outside the type token and the quoted command, beside a quote of a
5780
+ command that failed (`_quotes_failure`), or - for an item that names a
5781
+ subagent, an agent, a provider or a model (SPAWN_WORDS) - in a turn whose
5782
+ spawn call failed at the provider (`spawn_failed`). A failure of another
5783
+ kind - an assertion, a typo, a grep that matched nothing - is doable work,
5784
+ whatever it is typed. So is a quota or rate limit on a model provider
5785
+ while another family the routing allows is up (`_limit_move`)."""
5786
+ kinds = {m.lastgroup for m in BLOCKER_TRIED.finditer(said)}
5787
+ rest = re.sub(BLOCKER_TRIED.pattern + BLOCKER_ABSENT, " ", item)
5788
+ rest = CODE_SPAN.sub(lambda m: " " if _quotes_failure(m.group(0), failed)
5789
+ else m.group(0), rest)
5790
+ if not any((ENV_ERROR if k == "env" else ACCESS_ERROR).search(rest)
5791
+ for k in kinds):
5792
+ return False
5793
+ if _limit_move(item, failed, spawn_failed):
5794
+ return False
5795
+ return bool(spawn_failed and SPAWN_WORDS.search(_tr_lower(item))) or \
5796
+ _quotes_failure(item, failed)
5797
+
5798
+
5799
+ # The move a refused limit item can make (`_limit_move`), added to the
5800
+ # residual-work refusal so the reply knows what satisfies it.
5801
+ LIMIT_MOVE = (" A quota or rate limit on one model family is no {ACCESS} or {ENV} "
5802
+ "while another is up: the work can move to %s - re-dispatch the "
5803
+ "slice there.")
5804
+
5805
+
5806
+ def _limit_move(item, failed=(), spawn_failed=False):
5807
+ """The family the work of `item` can move to, else None (research
5808
+ orchestrator-flow-audit S2, F3b): the item quotes a quota or rate limit
5809
+ (`tezgah_task.classify`) of a model provider - it names a family
5810
+ (`tezgah_models.named_families`), or the turn's spawn failed at the provider
5811
+ and the item quotes no failed command - and a family the routing allows is
5812
+ up (`tezgah_models.other_family_up`), never one the item names. A GitHub
5813
+ or npm rate limit stays a blocker: words such as "agent" or "provider" tie
5814
+ nothing ("user-agent", "registry provider"). Fails open: None on any error."""
5815
+ try:
5816
+ import tezgah_task
5817
+ if tezgah_task.classify(item) not in ("quota", "rate_limit"):
5818
+ return None
5819
+ import tezgah_models
5820
+ if not (tezgah_models.named_families(item)
5821
+ or (spawn_failed and not _quotes_failure(item, failed))):
5822
+ return None
5823
+ return tezgah_models.other_family_up(item)
5824
+ except Exception: # noqa: BLE001 - a health read never refuses on its own failure
5825
+ return None
5826
+
5827
+
5828
+ # An item under a blocker label that tells a blocker it already removed: an
5829
+ # earlier sentence in the past ("reddediyordu", "yoktu") and a last sentence
5830
+ # that opens on "Artık" with a present verb ("Artık var", "Artık
5831
+ # reddedilmiyor", S4 finding 10). A negative past ("güncellenmedi") reports work
5832
+ # not done, and "şimdi"/"kendi" only end like a past tense, so neither is WAS.
5833
+ # A last sentence that still names work left (a RESIDUAL_LABEL stem) or a
5834
+ # future ("koşulacak") keeps the item open. ponytail: one adverb rule, not a
5835
+ # tense reader.
5836
+ BLOCKER_LABEL = re.compile(r"\W*(?:%s)" % alts("blocker_labels"))
5837
+ WAS = re.compile(r"\b(?!(?:%s)\b)(?:%s)\b" % (alts("was_exclude"), alts("was")))
5838
+ NOW = re.compile(alts("now"), re.S)
5839
+ FUTURE = re.compile(alts("future"))
5840
+
5841
+
5842
+ def _now_closed(item, label):
5843
+ """True when `item`, under a blocker `label`, reports a blocker that is
5844
+ gone (WAS, then NOW, with nothing left or due in the last sentence)."""
5845
+ if not BLOCKER_LABEL.match(_tr_lower(label)):
5846
+ return False
5847
+ sentences = re.split(r"(?<=[.!?])\s+", _tr_lower(_prose(item)).strip())
5848
+ last = sentences[-1].strip(" *_")
5849
+ return (len(sentences) > 1 and bool(NOW.search(last))
5850
+ and not RESIDUAL_LABEL.search(last) and not FUTURE.search(last)
5851
+ and any(WAS.search(s) for s in sentences[:-1]))
5852
+
5853
+
5854
+ # The ask policy (plan 086, S3; the CORE integrity paragraph's "Ask policy").
5855
+ # Its vocabulary is the packs' `ask` map: the process-question shapes
5856
+ # (PROCESS_ASK: "Başlayayım mı?", "shall I proceed?", "which first?"), the
5857
+ # never-default class (NEVER_DEFAULT, and SPEND over `spend_cap`), a
5858
+ # recommended default (DEFAULT_MARK), a decision's options (OPTIONS_MARK), a
5859
+ # bare approval (CHEAP_ANSWER) and the outward classes a grant covers
5860
+ # (OUTWARD). QUESTION_END is a last line asking the user (`?`, or a pack's
5861
+ # question particle). ponytail: word lists read on the last line, not a reader
5862
+ # of what the question means; `tezgah-gate replay` counts what it refuses
5863
+ # (`process_questions`) to measure its precision.
5864
+ QUESTION_END = re.compile(r"(?:\?|\b(?:%s)[.!]?)$" % alts("question_end"))
5865
+ PROCESS_ASK = re.compile(alts("ask", "process"), re.I)
5866
+ NEVER_DEFAULT = re.compile(alts("ask", "never_default"), re.I)
5867
+ SPEND = re.compile(alts("ask", "spend"), re.I)
5868
+ AMOUNT = re.compile(r"\$\s?(\d+(?:\.\d+)?)|(\d+(?:\.\d+)?)\s?(?:\$|usd\b|dolar)", re.I)
5869
+ DEFAULT_MARK = re.compile(alts("ask", "default_mark"), re.I)
5870
+ OPTIONS_MARK = re.compile(alts("ask", "options"), re.I)
5871
+ CHEAP_ANSWER = re.compile(r"\W*(?:%s)\W*" % alts("ask", "cheap_answer"), re.I)
5872
+ OUTWARD = {cls: re.compile("|".join(
5873
+ p for out in pack_values("ask", "outward") for c, p in out.items() if c == cls), re.I)
5874
+ for cls in dict.fromkeys(c for out in pack_values("ask", "outward") for c in out)}
5875
+ GRANT_KIND = "grant"
5876
+ GRANT_ROW = re.compile(r'"kind"\s*:\s*"%s"' % GRANT_KIND)
5877
+ ASK_TOOLS = ("ask", "askuserquestion")
5878
+ # Filled with the question the reply ends on. No grant shape in it ("without
5879
+ # asking"): omp hands a refusal back as a prompt, and the prompt hook reads
5880
+ # grants off every prompt (`tezgah_context.user_grants`).
5881
+ NEEDLESS_ASK = (
5882
+ "{ASK_LEAD}The reply ends on a question the agent can decide (\"%s\"). "
5883
+ "Work and process steps - the order, \"shall I go on\", a check, a local "
5884
+ "merge, a run parameter within the standing spend cap - are taken and "
5885
+ "stated in one line, and an outward step the user granted this session is "
5886
+ "taken, not asked again. An outward step the user has not granted waits "
5887
+ "for the answer and never proceeds on a default. Do it now, or, when only "
5888
+ "the user can decide it, put it under a \"{APPROVAL_HEADING}\" heading with "
5889
+ "a \"{RECOMMENDED}:\" default and proceed on that default.")
5890
+
5891
+
5892
+ def outward_classes(text):
5893
+ """The OUTWARD classes `text` names (push, release, publish, pr)."""
5894
+ low = _tr_lower(str(text or ""))
5895
+ return {cls for cls, rx in OUTWARD.items() if rx.search(low)}
5896
+
5897
+
5898
+ def never_default(text):
5899
+ """True when `text` names a class-F step - one no default may take: a
5900
+ force push, a delete of what the session did not make, production, a
5901
+ credential or an account, a send to a third party (NEVER_DEFAULT), or a
5902
+ spend (SPEND) with no amount or one over `spend_cap`."""
5903
+ low = _tr_lower(str(text or ""))
5904
+ if NEVER_DEFAULT.search(low):
5905
+ return True
5906
+ if not SPEND.search(low):
5907
+ return False
5908
+ amounts = [float(a or b) for a, b in AMOUNT.findall(low)]
5909
+ return not amounts or max(amounts) > spend_cap()
5910
+
5911
+
5912
+ def grants_in_force(session_id):
5913
+ """The OUTWARD classes the user granted this session and has not lifted,
5914
+ folded oldest first over the session's `grant` rows (`target`: a class,
5915
+ `all`, or `lift:<class|all>`). Only the lines in the row's serialized shape
5916
+ are parsed, as `ready_left` reads `todo` rows."""
5917
+ granted = set()
5918
+ lines = [line for line in _ledger_lines(_path(session_id)) if GRANT_ROW.search(line)] \
5919
+ if session_id else []
5920
+ for row in _parse(lines):
5921
+ target = str(row.get("target") or "")
5922
+ if target == "all":
5923
+ granted = set(OUTWARD)
5924
+ elif target == "lift:all":
5925
+ granted = set()
5926
+ elif target.startswith("lift:"):
5927
+ granted.discard(target[len("lift:"):])
5928
+ elif target in OUTWARD:
5929
+ granted.add(target)
5930
+ return granted
5931
+
5932
+
5933
+ def needless_ask(text, granted=frozenset):
5934
+ """The question the reply ends on when the agent can decide it, else None.
5935
+
5936
+ The last line asks (QUESTION_END), its paragraph carries no recommended
5937
+ default (DEFAULT_MARK) and it names no class-F step (`never_default`); it
5938
+ is then needless when it names an outward step (OUTWARD) the user granted
5939
+ this session, every one it names (`granted()`, called only then, so a reply
5940
+ that ends on no question reads no ledger), or, naming none, when it asks a
5941
+ process question (PROCESS_ASK). A question about an ungranted outward step
5942
+ is the one the policy asks: "push edip PR açayım mı?" waits."""
5943
+ t = str(text or "").strip()
5944
+ lines = [ln.strip().rstrip("*_` ") for ln in t.splitlines() if ln.strip()]
5945
+ if not lines or not QUESTION_END.search(lines[-1]):
5946
+ return None
5947
+ last = lines[-1]
5948
+ if never_default(last) or DEFAULT_MARK.search(_tr_lower(re.split(r"\n\s*\n", t)[-1])):
5949
+ return None
5950
+ low = _tr_lower(last)
5951
+ outward = outward_classes(low)
5952
+ if outward:
5953
+ return last if outward <= set(granted()) else None
5954
+ if PROCESS_ASK.search(low):
5955
+ return last
5956
+ return None
5957
+
5958
+
5959
+ def undefaulted(inp):
5960
+ """(questions, with a default) of an ask-tool payload: omp's `ask` marks
5961
+ the default with a question's `recommended` index, Claude's
5962
+ AskUserQuestion with an option label or description that names it
5963
+ (DEFAULT_MARK: "(Recommended)", "(önerilen)")."""
5964
+ inp = inp if isinstance(inp, dict) else {}
5965
+ qs = inp.get("questions") if isinstance(inp.get("questions"), list) else [inp]
5966
+ qs = [q for q in qs if isinstance(q, dict) and q.get("question")]
5967
+
5968
+ def has_default(q):
5969
+ if isinstance(q.get("recommended"), int) and not isinstance(q.get("recommended"), bool):
5970
+ return True
5971
+ return any(isinstance(o, dict) and DEFAULT_MARK.search(_tr_lower(
5972
+ "%s %s" % (o.get("label") or "", o.get("description") or "")))
5973
+ for o in q.get("options") or ())
5974
+ return len(qs), sum(1 for q in qs if has_default(q))
5975
+
5976
+
5977
+ # A blocker heading with nothing under it lists no blocker ("## Engeller" last).
5978
+ BLOCKERS_ONLY = re.compile(r"\s*(?:%s)\s*" % alts("blockers_only"))
5979
+
5980
+
5981
+ def unblocked_items(text, rows):
5982
+ """[(label, item)] for every residual item nothing accounts for. A GERİ
5983
+ ALINAMAZ token in the label, the intro or the item accounts for it, and so
5984
+ does a KARAR token beside the decision's options or a recommended default
5985
+ (`BLOCKER_SAID`, OPTIONS_MARK, DEFAULT_MARK, on the prose): a choice with
5986
+ neither is one the agent can make (the ask policy). A YETKİ or ORTAM token
5987
+ does only with its
5988
+ evidence (`_tried_holds`: the error of its type and its ledger evidence) -
5989
+ the type and its evidence, never one of the two. An empty blocker list
5990
+ (BLOCKERS_ONLY), an item that says nothing is left and one that reports a
5991
+ blocker already gone (`_now_closed`) are none; an empty item under any other
5992
+ label is the label itself ("**V4 ölçülmedi.**")."""
5993
+ failed = _failed_commands(rows)
5994
+ spawn_failed = _spawn_failed(rows)
5995
+ out = []
5996
+ for label, intro, items in _sections(text):
5997
+ for item in items:
5998
+ if (EMPTY_ITEM.fullmatch(item.strip()) or _now_closed(item, label) or (
5999
+ not item.strip() and BLOCKERS_ONLY.fullmatch(_tr_lower(label)))):
6000
+ continue
6001
+ said = _prose(" ".join((label, intro, item)))
6002
+ if BLOCKER_SAID.search(said) and (
6003
+ IRREVERSIBLE_SAID.search(said) or OPTIONS_MARK.search(_tr_lower(said))
6004
+ or DEFAULT_MARK.search(_tr_lower(said))):
6005
+ continue
6006
+ if BLOCKER_TRIED.search(said) and _tried_holds(item, said, failed,
6007
+ spawn_failed):
6008
+ continue
6009
+ out.append((label, item))
6010
+ return out
6011
+
6012
+
6013
+ def _residual_block(text, rows, shape, verdict=(None, None), session_id=None,
6014
+ cwd=None):
6015
+ """`verdict` when it refuses; else, in a turn that did work (a STEP_KINDS
6016
+ row), the `residual work` class when `unblocked_items` names one, or when a
6017
+ subagent ended in the turn handing up items no type accounts for (the
6018
+ `residual` count on its `subagent_end` row) and this reply types none; else
6019
+ the `needless ask` class when `needless_ask` names the question the reply
6020
+ ends on (any turn, worked or not: the plan proposal that ends "Başlayayım
6021
+ mı?" is its measured case); else the `ready steps` class when `ready_left`
6022
+ names one; else the
6023
+ `stopped slice` class when `_stopped_slice` finds one; else `verdict`. Not
6024
+ asked for a subagent's end (`shape` false) or a nested session: both
6025
+ replies are read by code, not by the user (`_shape_block`). Residual work
6026
+ is not asked of a session whose ledger holds no turn marker either:
6027
+ `turn_rows` hands it the whole ledger as the turn, so the steps it sees are
6028
+ not this turn's. ponytail: one typed item in the reply covers every item
6029
+ handed up - the rule cannot tell which child item a parent item answers."""
6030
+ if verdict[0] or not shape or os.environ.get("TEZGAH_NESTED"):
6031
+ return verdict
6032
+ left = []
6033
+ # the newest end of each child counts once (`agent`): a child that ended
6034
+ # twice, or a re-sent end, is one hand-up
6035
+ handed = sum({r.get("agent") or id(r): int(r.get("residual") or 0)
6036
+ for r in rows if r.get("kind") == "subagent_end"}.values())
6037
+ if handed or any(str(r.get("kind")) in STEP_KINDS for r in rows):
6038
+ _settle_slices(cwd)
6039
+ left = unblocked_items(text, rows)
6040
+ if handed and not left and not any(
6041
+ item.strip() and not EMPTY_ITEM.fullmatch(item.strip())
6042
+ for _label, _intro, entries in _sections(text) for item in entries):
6043
+ left = [("subagent report", "%d untyped item(s) handed up" % handed)]
6044
+ if left and session_id and not _turn_count(_ledger_lines(_path(session_id))):
6045
+ left = []
6046
+ if left:
6047
+ named = "; ".join("%s: %s" % (label, cut(" ".join(item.split()), 48))
6048
+ for label, item in left[:3])
6049
+ failed, spawn_failed = _failed_commands(rows), _spawn_failed(rows)
6050
+ move = next(filter(None, (_limit_move(item, failed, spawn_failed)
6051
+ for _label, item in left)), None)
6052
+ return ("residual work", RESIDUAL_WORK % named + (LIMIT_MOVE % move if move else ""))
6053
+ asked = needless_ask(text, lambda: grants_in_force(session_id))
6054
+ if asked:
6055
+ return ("needless ask", NEEDLESS_ASK % cut(asked, 80))
6056
+ ready = ready_left(text, rows, session_id, cwd)
6057
+ if ready:
6058
+ return ("ready steps", READY_STEPS % (
6059
+ len(ready), "; ".join(cut(head, 48) for head in ready[:3])))
6060
+ stopped = _stopped_slice(session_id, cwd)
6061
+ if not stopped:
6062
+ return verdict
6063
+ sid = str(stopped.get("id") or "")
6064
+ return ("stopped slice", STOPPED_SLICE % (
6065
+ sid, stopped.get("failure") or "error", sid, sid))
6066
+
6067
+
6068
+ def ready_heads(row, session_id, cwd):
6069
+ """A `todo` row's ready heads (`note_todo`), less each one an open slice of
6070
+ this session is working on: the slice id appears in the head, or the whole
6071
+ head in the slice's own task (`task`, without an omp batch's shared
6072
+ context), each as a word-bounded match of two characters or more,
6073
+ case-insensitive. omp's `todo start` sets every other `in_progress` item
6074
+ back to `pending`, so a dispatched item reads as ready in the list while
6075
+ its slice runs. ponytail: a record written before `task` existed falls
6076
+ back to its brief's last paragraph. Read from `tezgah_task.pending`, off
6077
+ under `slices-off`, and fail open to the row's own heads."""
6078
+ heads = [str(h) for h in row.get("heads") or () if str(h).strip()]
6079
+ if not (heads and session_id and cwd) or off("slices-off"):
6080
+ return heads
6081
+ try:
6082
+ pending = getattr(_journal(), "pending", None)
6083
+ root = _toplevel(os.path.realpath(cwd))
6084
+ running = [(str(r.get("id") or "").lower(), " ".join(str(
6085
+ r.get("task") or str(r.get("brief") or "").rsplit("\n\n", 1)[-1]
6086
+ ).split()).lower())
6087
+ for r in (pending(root) if pending and root else [])
6088
+ if r.get("status") == "open" and r.get("parent") == session_id]
6089
+ except Exception:
6090
+ return heads
6091
+
6092
+ def word(needle, hay):
6093
+ return len(needle) > 1 and re.search(
6094
+ r"(?<![\w-])%s(?![\w-])" % re.escape(needle), hay)
6095
+ return [h for h in heads
6096
+ if not any(word(sid, h.lower()) or word(h.lower(), task)
6097
+ for sid, task in running)]
6098
+
6099
+
6100
+ def newest_todo(rows):
6101
+ """The first `todo` row of the session's own list in `rows`, which run
6102
+ newest first, else None: a subagent's row (`agent`) is its own queue,
6103
+ never the parent's. Lazy, so a generator stops at the row it needs."""
6104
+ return next((r for r in rows if r.get("kind") == "todo" and not r.get("agent")), None)
6105
+
6106
+
6107
+ def ready_left(text, rows, session_id, cwd=None):
6108
+ """The heads of the ready steps the reply leaves: the newest `todo` row's
6109
+ ready items (`ready_heads`), less each one a line of the reply names (its
6110
+ first 24 characters, inline code kept) beside a KARAR, YETKİ, GERİ ALINAMAZ
6111
+ or ORTAM token outside code. Asked like `residual work`: in a turn that
6112
+ did work, of a ledger with a turn marker, off under `orchestrate-off`. The
6113
+ row may come from an earlier turn: an unfinished step carries over. It is
6114
+ found by its serialized shape and only the lines back to the newest
6115
+ parent `todo` row are parsed, so the Stop path still parses the turn and
6116
+ not the session (StopReadBound). A done item is gone from the list, so it
6117
+ never needs the reply."""
6118
+ if not session_id or off("orchestrate-off"):
6119
+ return []
6120
+ if not any(str(r.get("kind")) in STEP_KINDS for r in rows):
6121
+ return []
6122
+ lines = _ledger_lines(_path(session_id))
6123
+ if not _turn_count(lines):
6124
+ return []
6125
+ row = newest_todo(r for line in reversed(lines) if TODO_ROW.search(line)
6126
+ for r in _parse([line]))
6127
+ if not row or not row.get("ready"):
6128
+ return []
6129
+ typed = [_tr_lower(" ".join(line.split())) for line in str(text or "").splitlines()
6130
+ if BLOCKER_SAID.search(_prose(line)) or BLOCKER_TRIED.search(_prose(line))]
6131
+ return [h for h in ready_heads(row, session_id, cwd)
6132
+ if not any(_tr_lower(h)[:24] in line for line in typed)]
6133
+
6134
+
6135
+ def _settle_slices(cwd):
6136
+ """Close the journal's ended omp records (`tezgah_task.pending`) before the
6137
+ residual rule picks a move, so a child's limit stop already holds its
6138
+ family (`close_slice`) and the move never names it. Off under
6139
+ `slices-off`; fails open."""
6140
+ if not cwd or off("slices-off"):
6141
+ return
6142
+ try:
6143
+ pending = getattr(_journal(), "pending", None)
6144
+ root = _toplevel(os.path.realpath(cwd))
6145
+ if pending and root:
6146
+ pending(root)
6147
+ except Exception: # noqa: BLE001 - a broken journal costs the hold, never the Stop
6148
+ pass
6149
+
6150
+
6151
+ def _stopped_slice(session_id, cwd):
6152
+ """The newest slice record this session spawned that the journal holds as
6153
+ stopped (`tezgah_task.pending`), else None. A turn that did no work is
6154
+ asked too: the turn after an omp `/clear` is exactly the one that has
6155
+ forgotten the slice. Its own record only (`parent`), so a sibling
6156
+ session's stopped slice never refuses this one. Off under `slices-off`,
6157
+ and fail open: a broken journal costs the class, never the Stop. Refused
6158
+ once per turn like every class (`_record_only`)."""
6159
+ if not (session_id and cwd) or off("slices-off"):
6160
+ return None
6161
+ try:
6162
+ pending = getattr(_journal(), "pending", None)
6163
+ root = _toplevel(os.path.realpath(cwd))
6164
+ for rec in (pending(root) if pending and root else []):
6165
+ if rec.get("status") == "stopped" and rec.get("parent") == session_id:
6166
+ return rec
6167
+ except Exception:
6168
+ return None
6169
+ return None
6170
+
6171
+
4889
6172
  # The workspaces a probe or a benchmark runs in rather than a user: the temp
4890
6173
  # trees (/tmp, macOS's /var/folders), an OpenResearch run copy (`local-runs/`)
4891
6174
  # and the arm-bench instrument's run tree. tezgah's own cache is the fourth,
@@ -4961,8 +6244,8 @@ ORPHANED = (
4961
6244
  "Evidence tampered: a passing check in this turn has no `began` row from the "
4962
6245
  "gate before it (counted as an orphan), so it was written outside the hooks "
4963
6246
  "or the rows of its call were removed, and the ledger cannot carry a done or "
4964
- "tested claim this turn. Run the check again through the tool, or say what "
4965
- "is unverified (\"doğrulanmadı\"); the ledger is the user's to inspect.")
6247
+ "tested claim this turn. Run the check again through the tool, or drop the "
6248
+ "claim and say what is unverified; the ledger is the user's to inspect.")
4966
6249
  BEGAN_ROW = re.compile(r'"kind"\s*:\s*"%s"' % BEGAN_KIND)
4967
6250
  UNLOCKED_ROW = re.compile(r'"%s"\s*:\s*1\b' % UNLOCKED)
4968
6251
  # How many rows before a `turn_rows` slice seed `_pair`'s waiting `began` rows: