@r1z4x/tezgah 1.6.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +140 -1
  2. package/MANIFEST +7 -0
  3. package/README.md +6 -3
  4. package/bin/tezgah-decisions +6 -14
  5. package/bin/tezgah-design +21 -5
  6. package/bin/tezgah-docs +18 -16
  7. package/bin/tezgah-doctor +152 -0
  8. package/bin/tezgah-lessons +4 -1
  9. package/bin/tezgah-research +249 -17
  10. package/bin/tezgah-route +70 -8
  11. package/bin/tezgah-setup +26 -13
  12. package/bin/tezgah-task +877 -60
  13. package/bin/tezgah-taste +56 -15
  14. package/docs/architecture.md +28 -11
  15. package/docs/clarity-baseline.json +2 -2
  16. package/docs/contract.md +108 -24
  17. package/docs/environment.md +3 -1
  18. package/docs/evidence.md +209 -28
  19. package/docs/feature-audit.md +1 -1
  20. package/docs/gate.md +78 -23
  21. package/docs/glossary.md +4 -4
  22. package/docs/hosts.md +8 -8
  23. package/docs/index.json +11 -5
  24. package/docs/judge.md +9 -3
  25. package/docs/models.md +75 -14
  26. package/docs/operations.md +133 -3
  27. package/docs/research.md +349 -15
  28. package/docs/skills.md +1 -1
  29. package/docs/status-line.md +5 -5
  30. package/docs/testing.md +20 -13
  31. package/docs/vision.md +12 -4
  32. package/hooks/hooks.json +2 -2
  33. package/hooks/lang/en.json +323 -0
  34. package/hooks/lang/tr.json +344 -0
  35. package/hooks/projects-auto-init.py +29 -1
  36. package/hooks/projects-posttooluse.py +24 -5
  37. package/hooks/projects-pretooluse.py +9 -4
  38. package/hooks/projects-stop.py +28 -16
  39. package/hooks/tezgah_agents.py +53 -17
  40. package/hooks/tezgah_clarity.py +7 -3
  41. package/hooks/tezgah_context.py +489 -438
  42. package/hooks/tezgah_gate.py +996 -80
  43. package/hooks/tezgah_integrity.py +1547 -197
  44. package/hooks/tezgah_models.py +427 -45
  45. package/hooks/tezgah_paths.py +147 -16
  46. package/hooks/tezgah_policy.py +261 -129
  47. package/hooks/tezgah_rank.py +29 -27
  48. package/hooks/tezgah_replay.py +200 -30
  49. package/hooks/tezgah_research.py +1303 -14
  50. package/hooks/tezgah_stopspec.py +7 -0
  51. package/hooks/tezgah_store.py +74 -27
  52. package/hooks/tezgah_task.py +1201 -40
  53. package/hooks/tezgah_taste.py +8 -4
  54. package/hosts/codex/hook.py +32 -13
  55. package/hosts/cursor/hook.py +32 -14
  56. package/hosts/dsh/hooks.json +2 -2
  57. package/hosts/omp/hook.py +44 -23
  58. package/hosts/omp/tezgah-hook.ts.in +93 -7
  59. package/hosts/opencode/plugins/tezgah.js +273 -6
  60. package/output-styles/tezgah.md +76 -42
  61. package/package.json +1 -1
  62. package/skills/i-have-adhd/EVALS.md +22 -2
  63. package/skills/i-have-adhd/SKILL.md +12 -5
  64. package/skills/plan-add/SKILL.md +27 -11
  65. package/skills/plan-add/render_table.py +8 -3
  66. package/skills/plan-sync/SKILL.md +5 -3
  67. package/skills/ponytail/EVALS.md +21 -2
  68. package/skills/ponytail/SKILL.md +4 -4
  69. package/skills/research/SKILL.md +89 -1
  70. package/skills/tezgah-contract/EVALS.md +52 -7
  71. package/skills/tezgah-contract/SKILL.md +154 -59
@@ -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,19 @@ 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. `taken` is
488
+ # the in-progress items' heads, and `partial` marks a Cursor `merge: true` list.
489
+ # `residual` is a `subagent_end` row's count of residual items no blocker type
490
+ # accounts for (`unblocked_items`): the parent's Stop owes them (S4 finding 2).
491
+ # `questions` and `defaults` are an `ask` row's counts (`note_ask`): the
492
+ # questions an ask-tool call put to the user and how many carried a
493
+ # recommended option. A `grant` row's `target` is the outward class a user's
494
+ # grant covers (`all`, or an OUTWARD key), or `lift:<class>` when a prompt took
495
+ # it back (`grants_in_force`).
486
496
  LEDGER_FIELDS = frozenset(("id", "exit", "out_bytes", "fail_class", "workspace",
487
497
  "source", "hash", "changed", "tool", "target",
488
498
  "lines", "chars", "items", "longest_list",
@@ -490,7 +500,9 @@ LEDGER_FIELDS = frozenset(("id", "exit", "out_bytes", "fail_class", "workspace",
490
500
  "summary_chars", "summary_hash",
491
501
  "constraint_found", "constraint_expected", "parent", "agent",
492
502
  "check", "key", "block", "repo", "empty_run", "cause",
493
- "host", "switches", "harness", "authorized", "inflight"))
503
+ "host", "switches", "harness", "authorized", "inflight",
504
+ "reask", "open", "ready", "blocked", "heads", "residual",
505
+ "questions", "defaults", "taken", "partial"))
494
506
 
495
507
  # The row contract's own version, stamped by the writer beside `kind` and `ts` so
496
508
  # it is not a caller field. It exists because a row is read back to decide a
@@ -900,8 +912,9 @@ DAMAGE_KIND = "ledger_damage"
900
912
  TAMPERED = (
901
913
  "Evidence tampered: a line of this session's evidence ledger is not a row "
902
914
  "(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.")
915
+ "claim this turn, nor the failed check an admission rests on. Drop the "
916
+ "claim, say what is unverified and tell the user the ledger was damaged; "
917
+ "the ledger is theirs to inspect.")
905
918
  # The damaged lines this process already recorded, so a reader that parses the
906
919
  # same tail on every gated call scans the file for the row once, not each time.
907
920
  _DAMAGE_SEEN = set()
@@ -1167,9 +1180,10 @@ def scratch_target(path, cwd=None):
1167
1180
  return False
1168
1181
 
1169
1182
 
1170
- def writers_elsewhere(path, session_id, minutes=10, cwd=None):
1183
+ def writers_elsewhere(path, session_id, minutes=10, cwd=None, lineage=False):
1171
1184
  """The other sessions that recorded a write of `path` in the last `minutes`,
1172
- newest first, as ledger ids.
1185
+ newest first, as ledger ids. With `lineage`, each one is a (ledger id,
1186
+ relation, agent, newest write ts) tuple instead (`_relation`).
1173
1187
 
1174
1188
  `path` is resolved against `cwd` (`_abs_target`) and matched against the
1175
1189
  `target` the writing session's PostToolUse hook stored, which is that
@@ -1214,7 +1228,74 @@ def writers_elsewhere(path, session_id, minutes=10, cwd=None):
1214
1228
  continue
1215
1229
  if isinstance(row, dict) and row.get("target") == want:
1216
1230
  found[stem] = max(found.get(stem, stamp), stamp)
1217
- return sorted(found, key=lambda stem: -found[stem])
1231
+ stems = sorted(found, key=lambda stem: -found[stem])
1232
+ return [(stem,) + _relation(stem, session_id) + (found[stem],)
1233
+ for stem in stems] if lineage else stems
1234
+
1235
+
1236
+ # The rows that name a session's lineage at its start: `spawned` (a subagent's
1237
+ # parent, written by the hosts' subagent start) and `forked` (an omp fork's
1238
+ # parent, written by the omp hook from the session header's `parentSession`).
1239
+ LINEAGE_KINDS = ("spawned", "forked")
1240
+
1241
+
1242
+ def _lineage(stem):
1243
+ """{kind: (parent ledger id, raw parent id, agent)} of the first row of each
1244
+ LINEAGE_KINDS in ledger `stem`; {} when it has none or cannot be read."""
1245
+ out = {}
1246
+ for kind in LINEAGE_KINDS:
1247
+ try:
1248
+ texts = ts.evidence_rows(os.path.join(cache_dir(), "evidence",
1249
+ stem + ".jsonl"), kind=kind)
1250
+ except ts.ERRORS:
1251
+ continue
1252
+ for text in texts:
1253
+ try:
1254
+ row = json.loads(text)
1255
+ except ValueError:
1256
+ continue
1257
+ if isinstance(row, dict) and row.get("parent"):
1258
+ raw = str(row["parent"])
1259
+ out[kind] = (_slug(raw), raw, str(row.get("agent") or ""))
1260
+ break
1261
+ return out
1262
+
1263
+
1264
+ def _relation(stem, session_id):
1265
+ """(relation, agent) of writer ledger `stem` to session `session_id`: `child`
1266
+ (its `spawned` row names this session; agent is the child's name),
1267
+ `parent`, `sibling` (both spawned by one parent), `fork` (an omp fork of
1268
+ this session), `forked-from` (the session this one was forked from), else
1269
+ `unrelated`. The race rule names it and exempts a child that has ended
1270
+ (research orchestrator-flow-audit S7, findings 3 and 4)."""
1271
+ theirs, mine = _lineage(stem), _lineage(_slug(session_id))
1272
+ spawn, fork = theirs.get("spawned"), theirs.get("forked")
1273
+ if spawn and spawn[1] == session_id:
1274
+ return "child", spawn[2]
1275
+ if fork and fork[1] == session_id:
1276
+ return "fork", ""
1277
+ own_spawn, own_fork = mine.get("spawned"), mine.get("forked")
1278
+ if own_spawn and own_spawn[0] == stem:
1279
+ return "parent", ""
1280
+ if own_fork and own_fork[0] == stem:
1281
+ return "forked-from", ""
1282
+ if spawn and own_spawn and spawn[1] == own_spawn[1]:
1283
+ return "sibling", spawn[2]
1284
+ return "unrelated", ""
1285
+
1286
+
1287
+ def spawn_of(session_id):
1288
+ """(raw parent session id, spawned name) from this session's `spawned` row,
1289
+ else None: how a subagent's own session finds its slice record (the lease
1290
+ rule, `tezgah_gate.lease_reason`). The name is omp's child file stem, so a
1291
+ re-dispatch reads `<name>-2`."""
1292
+ hit = _lineage(_slug(session_id)).get("spawned") if session_id else None
1293
+ return (hit[1], hit[2]) if hit and hit[2] else None
1294
+
1295
+
1296
+ def _prompt_key(text):
1297
+ """The key a `turn` row stores for one prompt text (`note_turn`)."""
1298
+ return hashlib.sha1(text.encode("utf-8", "replace")).hexdigest()[:12]
1218
1299
 
1219
1300
 
1220
1301
  def note_turn(session_id, prompt, workspace=None):
@@ -1246,7 +1327,7 @@ def note_turn(session_id, prompt, workspace=None):
1246
1327
  written in between, resets nothing - that direction can only deny too much,
1247
1328
  never too little."""
1248
1329
  text = str(prompt or "")
1249
- key = hashlib.sha1(text.encode("utf-8", "replace")).hexdigest()[:12]
1330
+ key = _prompt_key(text)
1250
1331
  rows = events(session_id, tail=1)
1251
1332
  if rows and rows[-1].get("kind") == "turn" and rows[-1].get("detail") == key:
1252
1333
  return
@@ -1563,10 +1644,12 @@ def counters(session_id, weeks=False, tools=False):
1563
1644
  carries, because a host that reports none would otherwise read as a perfect
1564
1645
  success rate while opencode's real process codes (2, 127, 130) count in
1565
1646
  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
1647
+ `false_completion` counts the claim rows a stop refused for their evidence
1648
+ - not for their shape (`SHAPE_BLOCKS`) or the work they leave
1649
+ (`LIST_BLOCKS`) - so the rate is false_completion / claims. `judge` counts
1650
+ the seam's own rows by kind: a judgement is a cost, so it is in neither
1651
+ STEP_KINDS nor the check set - it can never make a "done" claim look
1652
+ backed. `shape` counts the report-only
1570
1653
  reply-shape rows (`shape_flags`) by kind for the same reason: a flag nothing
1571
1654
  counted was indistinguishable from a flag that never fires.
1572
1655
 
@@ -1734,8 +1817,9 @@ def _counts(rows, weeks=False, tools=False):
1734
1817
  cls = detail[len("blocked: "):]
1735
1818
  out["blocked_claims"][cls] = \
1736
1819
  out["blocked_claims"].get(cls, 0) + 1
1737
- out["false_completion"] += 1
1738
- refused = True
1820
+ if cls not in LIST_BLOCKS:
1821
+ out["false_completion"] += 1
1822
+ refused = True
1739
1823
  if weeks:
1740
1824
  # the same bucket this row's events went into, so a claim cannot
1741
1825
  # sit in a week's total and outside its ratio
@@ -3178,9 +3262,62 @@ def unanswered(rows, delivered=()):
3178
3262
  return sum(1 for row in waiting if row.get("tool") in seen)
3179
3263
 
3180
3264
 
3265
+ # The host todo list is the orchestrator's step queue (plan 086, S1): each open
3266
+ # item is ready, in progress or blocked. These tools carry the whole list: omp's
3267
+ # `todo` in its result (`details.phases`, which the bridge sends as `todo`, since
3268
+ # the call holds one op), Claude's and dsh's `TodoWrite`, opencode's `todowrite`
3269
+ # and Cursor's `todo_write` in `todos[]`, Codex's `update_plan` in `plan[]`.
3270
+ TODO_TOOLS = frozenset(("todo", "todowrite", "todo_write", "update_plan"))
3271
+ # The statuses that close an item. `pending` is ready; `in_progress` is open and
3272
+ # taken, so it is not ready; omp's `blocked` (an item with a `blocker`) is open
3273
+ # and waits on something named.
3274
+ TODO_CLOSED = frozenset(("completed", "done", "cancelled", "abandoned"))
3275
+ TODO_ROW = re.compile(r'"kind"\s*:\s*"todo"')
3276
+ # How much of each ready item the row keeps: a head that names it in a refusal.
3277
+ TODO_HEAD = 60
3278
+
3279
+
3280
+ def note_todo(session_id, tool, inp, snapshot=None, cwd=None, agent=None):
3281
+ """Write one `todo` row for a todo call: the `open`, `ready` and `blocked`
3282
+ counts, every ready item's head in `heads` (redacted, TODO_HEAD characters
3283
+ each), every in-progress item's head in `taken`, and the ready heads in
3284
+ `detail`, one per line, which the row's cut may shorten. A call whose list
3285
+ is not in hand (an omp `todo` result without its `details`) writes nothing,
3286
+ so it cannot read as an empty queue. An item whose own text types a blocker
3287
+ (KARAR, YETKİ, GERİ ALINAMAZ, ORTAM, or `blocked`) counts as blocked:
3288
+ Claude's and Codex's lists have no blocked status. `agent` is the host's
3289
+ subagent id: a subagent's list is its own, and the readers skip it. A
3290
+ Cursor `merge: true` call carries only the items it changes: its row is
3291
+ marked `partial`, which the `open todo` class skips. ponytail: the counts
3292
+ of a partial row can still run low for `ready steps`."""
3293
+ raw = snapshot if snapshot is not None else inp.get("todos", inp.get("plan"))
3294
+ if not isinstance(raw, list):
3295
+ return
3296
+ items = [(str(i.get("content") or i.get("step") or "").strip(),
3297
+ "blocked" if i.get("blocker") else str(i.get("status") or "pending").lower())
3298
+ for i in raw if isinstance(i, dict)]
3299
+ items = [(text, status) for text, status in items
3300
+ if text and status not in TODO_CLOSED]
3301
+ blocked = [text for text, status in items if status == "blocked" or
3302
+ BLOCKER_SAID.search(text) or BLOCKER_TRIED.search(text)
3303
+ or re.search(r"\bblocked\b", text, re.I)]
3304
+
3305
+ def head(text):
3306
+ return _stored_text(" ".join(text.split()))[:TODO_HEAD]
3307
+ heads = [head(text) for text, status in items
3308
+ if status == "pending" and text not in blocked]
3309
+ taken = [head(text) for text, status in items
3310
+ if status != "pending" and text not in blocked]
3311
+ note(session_id, "todo", "\n".join(heads),
3312
+ id=call_id(tool, inp), tool=str(tool or "").strip() or None,
3313
+ workspace=root_for(cwd) if cwd else None, agent=agent or None,
3314
+ open=len(items), ready=len(heads), blocked=len(blocked), heads=heads,
3315
+ taken=taken, partial=True if snapshot is None and inp.get("merge") else None)
3316
+
3317
+
3181
3318
  def note_tool(session_id, tool, inp, failed=None, *, interrupted=False,
3182
3319
  out_bytes=None, error=None, cwd=None, source=None, empty_run=False,
3183
- agent=None):
3320
+ agent=None, host=None, todo=None, child=None):
3184
3321
  """Record the evidence kind for one tool call (host PostToolUse hooks).
3185
3322
 
3186
3323
  `failed=None` is the default because a host that passes no argument reported
@@ -3224,8 +3361,24 @@ def note_tool(session_id, tool, inp, failed=None, *, interrupted=False,
3224
3361
  check exited 0 over nothing, so it records as one that RAN (`verify`), the
3225
3362
  piped check's kind, and the turn still owes a pass.
3226
3363
  `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)."""
3364
+ row's `agent` field so the turn readers can key on (session, agent).
3365
+ `host` names the calling host for the slice journal (`_note_slice`); None
3366
+ reads it off the hook process (`_hook_host`). `child` is the id a spawn's
3367
+ result gave a background child (Claude's `agentId`), stored on its slice
3368
+ record so the lease rule finds it. A todo call (TODO_TOOLS)
3369
+ writes its `todo` row instead (`note_todo`), from `todo` when the host's
3370
+ list is in the result rather than the call (omp). omp's `wait` writes a
3371
+ `wait` row: no step, the call the idle-wait warning counts as idle."""
3228
3372
  inp = inp or {}
3373
+ name = str(tool or "").strip().lower()
3374
+ if name in TODO_TOOLS:
3375
+ if not failed and not interrupted:
3376
+ note_todo(session_id, tool, inp, todo, cwd, agent)
3377
+ return
3378
+ if name == "wait":
3379
+ note(session_id, "wait", "", id=call_id(tool, inp), tool="wait",
3380
+ workspace=root_for(cwd) if cwd else None)
3381
+ return
3229
3382
  cmd = str(inp.get("command") or inp.get("cmd") or "")
3230
3383
  kind = classify(tool, inp)
3231
3384
  if kind == "verify":
@@ -3314,6 +3467,7 @@ def note_tool(session_id, tool, inp, failed=None, *, interrupted=False,
3314
3467
  fields["repo"] = _check_repo(cmd, cwd, inp)
3315
3468
  fields["empty_run"] = True if empty_run else None
3316
3469
  note(session_id, kind, detail, **fields)
3470
+ _note_slice(session_id, tool, inp, failed, interrupted, error, cwd, host, child)
3317
3471
  if kind == "edit" and not failed:
3318
3472
  # the taste capture (tezgah_taste, opt-in): the edit's text under this
3319
3473
  # row's id, and the bytes it left - read here because only PostToolUse
@@ -3325,6 +3479,84 @@ def note_tool(session_id, tool, inp, failed=None, *, interrupted=False,
3325
3479
  tezgah_taste.note_write(session_id, fields["id"], inp, cwd)
3326
3480
 
3327
3481
 
3482
+ # The hosts whose post event for a spawn call arrives once the child finished,
3483
+ # so the call's outcome is the slice's: Claude/dsh/Cursor `Agent`/`Task`. omp's
3484
+ # `task` and Codex `spawn_agent` return while the child still runs (a Codex
3485
+ # rollout shows the call answered with its `task_name` alone), so their records
3486
+ # stay open: omp's for `tezgah_task.pending` to close from the child
3487
+ # transcripts, Codex's until `pending` closes them `stale`.
3488
+ SYNC_SPAWN_HOSTS = frozenset(("claude", "dsh", "cursor"))
3489
+
3490
+
3491
+ def _journal():
3492
+ """The slice journal module (tezgah_task), or None when it is missing or
3493
+ broken: the journal is a record, and the post-tool path must never pay for
3494
+ it. Imported here, not at the top: tezgah_task is newer than some checkouts."""
3495
+ try:
3496
+ import tezgah_task
3497
+ except Exception:
3498
+ return None
3499
+ return tezgah_task
3500
+
3501
+
3502
+ def _hook_host():
3503
+ """The host this hook process runs for (`tezgah_guard._host`): `hosts/<name>/`
3504
+ by its directory, and the shared Claude/dsh script told apart by the dsh
3505
+ manifest's `TEZGAH_CALL_OUTCOME=none`."""
3506
+ try:
3507
+ from tezgah_guard import _host
3508
+ name = _host()
3509
+ except Exception:
3510
+ return ""
3511
+ if name == "claude/dsh":
3512
+ return "dsh" if os.environ.get("TEZGAH_CALL_OUTCOME") == "none" else "claude"
3513
+ return name
3514
+
3515
+
3516
+ def _note_slice(session_id, tool, inp, failed, interrupted, error, cwd, host,
3517
+ child=None):
3518
+ """Hand a spawn call to the slice journal (`tezgah_task.note_spawn`), and
3519
+ open a `job` record for an omp `bash` call sent to the background, so a
3520
+ `/clear` keeps the pointer to the run. Swallows every exception.
3521
+
3522
+ On a synchronous host the slice is closed here: the event arriving means
3523
+ the child is no longer running, so a call with no failure signal (dsh's
3524
+ bridge sends none) closes as done rather than staying open forever - a
3525
+ journal entry, never a pass on the ledger. A Claude call sent with
3526
+ `run_in_background`, or whose result names a background `child`, returns
3527
+ before its child ends and stays open."""
3528
+ name = str(tool or "").strip().lower()
3529
+ if not cwd:
3530
+ return
3531
+ try:
3532
+ tt = _journal()
3533
+ host = host or _hook_host()
3534
+ if name in SUBAGENT_TOOLS:
3535
+ spawn = getattr(tt, "note_spawn", None)
3536
+ if spawn is None:
3537
+ return
3538
+ sync = (host in SYNC_SPAWN_HOSTS and inp.get("run_in_background") is not True
3539
+ and not child)
3540
+ text = str(error or "") or ("aborted" if interrupted else "")
3541
+ # Codex's hook maps `spawn_agent` onto `Task` before this row; the
3542
+ # journal parses the call by the host's own name and fields
3543
+ spawn(cwd, session_id, host,
3544
+ "spawn_agent" if host == "codex" else tool, inp,
3545
+ failed=bool(failed or interrupted or error) if sync else None,
3546
+ result_text=text, **({"child": child} if child else {}))
3547
+ elif host == "omp" and name == "bash" and inp.get("async") is True:
3548
+ opener = getattr(tt, "open_slice", None)
3549
+ root = _toplevel(os.path.realpath(cwd))
3550
+ cmd = str(inp.get("command") or "")
3551
+ if opener is None or not root or not cmd or off("slices-off"):
3552
+ return
3553
+ opener(root, "job-" + hashlib.sha1(cmd.encode()).hexdigest()[:10],
3554
+ kind="job", host="omp", brief=cmd, parent=str(session_id or ""),
3555
+ cwd=cwd)
3556
+ except Exception:
3557
+ pass
3558
+
3559
+
3328
3560
  # One leading step a check's command opens with: a subshell paren, an
3329
3561
  # assignment (`export X=1`, `X=1`) or a `cd X`, closed by `&&` or `;`.
3330
3562
  LEAD_STEP = re.compile(r"\s*\(?\s*(?:(?:export\s+)?\w+=\S*|cd\s+(\S+))\s*(?:&&|;)")
@@ -3399,9 +3631,7 @@ def ran_nothing(result):
3399
3631
  # `claims` is the false-done reader the benchmarks count with; the Stop verdict
3400
3632
  # and its claim row read the vocabulary (`asserted_claims`), where an admitted
3401
3633
  # 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)
3634
+ ADMITTED = re.compile(alts("admitted"), re.I)
3405
3635
 
3406
3636
 
3407
3637
  def claims(text):
@@ -3420,10 +3650,10 @@ def claims(text):
3420
3650
  # the clause after it. So "Don't worry, it's done.", "All tests pass so should
3421
3651
  # I open the PR?", "Done (no regressions?)" and "Tamamlandı, push edeyim mi?"
3422
3652
  # all still assert.
3423
- NEGATION_BEFORE = re.compile(r"(?:\bnot|n't|\bnever|\bdeğil)(?:\s+\S+){0,2}\s+\Z",
3653
+ NEGATION_BEFORE = re.compile(r"(?:%s)(?:\s+\S+){0,2}\s+\Z" % alts("negation_before"),
3424
3654
  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",
3655
+ QUESTION_PARTICLE = re.compile(r"\s*(?:%s)\b" % alts("question_particle"), re.I)
3656
+ CLAUSE_END = re.compile(r"[.!?\n,;:\u2014\u2013()]|\b(?:%s)\b" % alts("clause_join"),
3427
3657
  re.I)
3428
3658
 
3429
3659
 
@@ -3763,9 +3993,11 @@ def last_verify(session_id):
3763
3993
  return _last_verify(events(session_id))
3764
3994
 
3765
3995
 
3766
- def last_check(session_id):
3996
+ def last_check(session_id, repo=None):
3767
3997
  """The newest verification row the ledger holds - a `verify_ok` or a
3768
3998
  `verify_fail`, with its command in `detail` - or None when no check ran.
3999
+ With `repo`, a check whose own `repo` names another repository is skipped
4000
+ (`_same_repo`): a check run in another project says nothing about this one.
3769
4001
 
3770
4002
  `last_verify` answers the same question as a state; this hands the row back
3771
4003
  for a reader that has to name the command the state came from, the way the
@@ -3775,7 +4007,8 @@ def last_check(session_id):
3775
4007
  buys out of."""
3776
4008
  newest = None
3777
4009
  for row in events(session_id, tail=SCRATCH_TAIL):
3778
- if row.get("kind") in ("verify_ok", "verify_fail"):
4010
+ if row.get("kind") in ("verify_ok", "verify_fail") \
4011
+ and _same_repo(row.get("repo"), repo):
3779
4012
  newest = row
3780
4013
  return newest
3781
4014
 
@@ -3935,15 +4168,34 @@ def prose_words(text):
3935
4168
  return words
3936
4169
 
3937
4170
 
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:
4171
+ def _markers(code):
4172
+ """Pack `code`'s reply markers as (words, letters), or None when it ships
4173
+ none: only such a language's replies are held to it."""
4174
+ marks = packs().get(code, {}).get("markers") or {}
4175
+ words, letters = frozenset(marks.get("words", ())), "".join(marks.get("letters", ()))
4176
+ return (words, letters) if words or letters else None
4177
+
4178
+
4179
+ def reply_share(words, code="tr"):
4180
+ """The share of `words` that mark language `code` (`_markers`): a marker
4181
+ letter in the word, or a marker word. None for an empty list or a language
4182
+ with no markers."""
4183
+ marks = _markers(code)
4184
+ if not words or not marks:
3942
4185
  return None
3943
- hits = sum(1 for w in words if w in TR_WORDS or TR_LETTERS.search(w))
4186
+ hits = sum(1 for w in words if w in marks[0] or any(c in marks[1] for c in w))
3944
4187
  return round(hits / len(words), 3)
3945
4188
 
3946
4189
 
4190
+ def _said(text):
4191
+ """`text` with each `{WORD}` placeholder filled with the configured
4192
+ language's contract word (`tezgah_paths.say`): the refusal names the
4193
+ tokens the contract the model was given names."""
4194
+ for key, word in say().items():
4195
+ text = text.replace("{%s}" % key.upper(), word)
4196
+ return text
4197
+
4198
+
3947
4199
  def reply_shape(text):
3948
4200
  """The reply's size and lead, as the `shape` row records them for every
3949
4201
  judged reply and the `claim` row beside its verdict: `lines` (line count),
@@ -3971,7 +4223,7 @@ def reply_shape(text):
3971
4223
  return {"lines": len(lines), "chars": len(text),
3972
4224
  "items": sum(1 for line in lines if ITEM.match(line)),
3973
4225
  "longest_list": longest_list(text),
3974
- "tr_share": turkish_share(prose_words(text)),
4226
+ "tr_share": reply_share(prose_words(text)),
3975
4227
  "answer_first": answer_first}
3976
4228
 
3977
4229
 
@@ -4045,8 +4297,7 @@ def _shape_block(text, cwd):
4045
4297
  "Start with the answer: \"Great question\" / \"Let me...\" / "
4046
4298
  "\"I'll...\" / \"Sure!\" / \"Looking at your...\" are never "
4047
4299
  "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.")
4300
+ "fix in one plain sentence - {OPENER_BAN}.")
4050
4301
  # only the last prose line is read, so a reply that ends on a code
4051
4302
  # block, a heading or a table is not judged here at all
4052
4303
  last = _closing_prose(lines)
@@ -4055,8 +4306,8 @@ def _shape_block(text, cwd):
4055
4306
  "Reply closes with a sign-off the output contract bans "
4056
4307
  "(rule 10: no preamble, no recap, no closer): \"Let me know "
4057
4308
  "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.")
4309
+ "to clarify\". End when the answer is done; what is left is "
4310
+ "done now, or named under \"{BLOCKERS_HEADING}\" as a blocker.")
4060
4311
  longest = longest_list(text)
4061
4312
  if longest > LIST_CAP:
4062
4313
  return ("list cap",
@@ -4067,25 +4318,97 @@ def _shape_block(text, cwd):
4067
4318
  "whole enumeration is the point, split it under headings of "
4068
4319
  "at most five items each, or give it as a table."
4069
4320
  % (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"):
4321
+ # `language` is the switch, and only a pack that ships reply markers (tr)
4322
+ # is judged: the markers tell that language from English, and an English
4323
+ # reply quotes the user's other language, so English is not held to.
4324
+ if _markers(language()) and not off("exec-mode.off"):
4074
4325
  words = prose_words(text)
4075
- share = turkish_share(words)
4326
+ share = reply_share(words, language())
4327
+ name = packs()[language()]["name"]
4076
4328
  if len(words) >= LANG_MIN_WORDS and share < LANG_MIN_SHARE:
4077
4329
  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, "
4330
+ "Reply prose is not in %s (%d prose words, %.0f%% of "
4331
+ "them %s). The contract's first rule: every user-facing "
4332
+ "reply is in %s, even when the user writes English. "
4333
+ "Rewrite the prose in %s; code, commands, paths, "
4082
4334
  "identifiers and quoted output stay as they are (put them in "
4083
- "backticks or a code block)." % (len(words), share * 100))
4335
+ "backticks or a code block)."
4336
+ % (name, len(words), share * 100, name, name, name))
4084
4337
  return (None, None)
4085
4338
 
4086
4339
 
4087
- def stop_reason(text, session_id, cwd=None, record_only=False,
4088
- subagent=False, agent=None):
4340
+ # How many times the Stop rule may refuse inside one user turn (plan 083): the
4341
+ # reply after a block is judged again and refused once more only for a class
4342
+ # the turn has not been refused for. Counted from the ledger's own `blocked:`
4343
+ # rows (`_turn_refusals`, `_record_only`), so every host gets the same budget
4344
+ # whatever its payload says about earlier blocks.
4345
+ STOP_REASKS = 2
4346
+
4347
+
4348
+ def _blocked(entry):
4349
+ """True for a Stop refusal row: a `blocked:` claim or refusal."""
4350
+ return (entry.get("kind") in ("claim", "refusal")
4351
+ and str(entry.get("detail", "")).startswith("blocked:"))
4352
+
4353
+
4354
+ def _turn_refusals(session_id, rows):
4355
+ """(this rule's refusal rows in the user turn `rows` belongs to, that turn's
4356
+ rows).
4357
+
4358
+ omp hands a Stop block back to the model as a queued message, and its
4359
+ prompt hook records that text as a new `turn` row (`note_turn`), so the
4360
+ reply after a block sits in a turn of its own. Such a marker carries the
4361
+ key of the refusal text (`_prompt_key`), which the refusal row stores as
4362
+ `reask`: a marker equal to the `reask` of the refusal just before it is no
4363
+ new user turn, and that turn's refusals count too. Read back no further
4364
+ than STOP_REASKS refusals, and only the lines that carry a `reask` key are
4365
+ parsed: the Stop path stays a read of the turn, not of the session
4366
+ (StopReadBound). When the chain reaches back, the rows returned run from
4367
+ the chain's first turn with the re-ask markers left out, so the reply after
4368
+ a block is judged on the turn's whole work, as on a host that writes no
4369
+ such marker (S4 finding 1)."""
4370
+ refused = [entry for entry in rows if _blocked(entry)]
4371
+ path = _path(session_id)
4372
+ lines = _ledger_lines(path)
4373
+ end = first = _turn_line(lines)
4374
+ while end and len(refused) < STOP_REASKS:
4375
+ marker = _parse([lines[end - 1]])
4376
+ start = _turn_line(lines[:end - 1])
4377
+ before = [entry for entry in _parse([line for line in lines[start:end - 1]
4378
+ if '"reask"' in line])
4379
+ if _blocked(entry)]
4380
+ if not (marker and before
4381
+ and before[-1].get("reask") == marker[0].get("detail")):
4382
+ break
4383
+ refused = before + refused
4384
+ end = start
4385
+ if end != first:
4386
+ rows = [row for row in _pair(_parse(lines[end:], path), path, lines,
4387
+ lines[:end])
4388
+ if row.get("kind") != TURN_KIND]
4389
+ return refused, rows
4390
+
4391
+
4392
+ def _record_only(refused, turns, after_block, cls):
4393
+ """True when this reply is recorded and not refused (plan 083).
4394
+
4395
+ `refused` holds the classes this rule refused in the user turn so far. A
4396
+ reply is refused only while there are fewer than STOP_REASKS of them and
4397
+ none is its class, so a class is refused at most once per turn and a
4398
+ third refusal cannot happen. `after_block` is the host's own count of the
4399
+ blocks before this reply - 1 for Claude's, Codex's and omp's
4400
+ `stop_hook_active`, Cursor's `loop_count` - and it rules only where the
4401
+ ledger cannot: a session with no `turn` row has no turn to count in, so
4402
+ there any earlier block makes the reply record-only (the one-nudge rule
4403
+ before plan 083), and a host count at the budget is record-only too."""
4404
+ if not turns:
4405
+ return bool(after_block)
4406
+ return (int(after_block or 0) >= STOP_REASKS or cls in refused
4407
+ or len(refused) >= STOP_REASKS)
4408
+
4409
+
4410
+ def stop_reason(text, session_id, cwd=None, after_block=0,
4411
+ subagent=False, agent=None, parent=None):
4089
4412
  """Why this turn must not end yet, or None. Used by the Stop hooks (Claude,
4090
4413
  Codex, omp and Cursor, which share the payload fields and the block envelope).
4091
4414
  `cwd` is the session's directory, read for the repo's `.no-adhd` mark.
@@ -4094,8 +4417,8 @@ def stop_reason(text, session_id, cwd=None, record_only=False,
4094
4417
  whole ledger: the file grows with the session, the handler runs once per
4095
4418
  turn, and the question - did this turn leave work unverified - is about the
4096
4419
  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.
4420
+ ledger here, which is what it got before the scope; it loses the bound, and
4421
+ `residual work` (which needs the turn's own steps) is not asked of it.
4099
4422
 
4100
4423
  The verdict is recorded as a `claim` row when the reply is in the claim
4101
4424
  vocabulary - a DONE/VERIFIED word (`asserted_claims`), blocked or not,
@@ -4106,8 +4429,8 @@ def stop_reason(text, session_id, cwd=None, record_only=False,
4106
4429
  `detail` carries the reason class - `blocked: no verify_ok`, `blocked: check
4107
4430
  failed`, `blocked: partial failure`, `blocked: stale evidence`, `blocked: no
4108
4431
  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
4432
+ floor), `blocked: no external read`, `blocked: evidence tampered`,
4433
+ `blocked: residual work` (plan 080), the shape classes in SHAPE_BLOCKS, or
4111
4434
  `ok` - so which branch refused a turn is readable; a `no verify_ok` row also
4112
4435
  carries `cause` (`_no_pass_cause`). One row per reply per turn: an identical
4113
4436
  row for the same key is skipped.
@@ -4117,26 +4440,35 @@ def stop_reason(text, session_id, cwd=None, record_only=False,
4117
4440
  list and language numbers the rule refuses on have a published rate over
4118
4441
  every reply, not only over the ones that made a claim.
4119
4442
 
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
4443
+ The reply after one of this rule's blocks - the turn already holds a
4444
+ `blocked:` claim or refusal row, or, in a session with no `turn` row, the
4445
+ host's `after_block` count says a block came before - is judged the same
4446
+ way. `_record_only` decides whether it may be refused again: once more in
4447
+ the turn, for a class not refused yet (plan 083). A refused one is a
4448
+ refusal like the first, `shape` row included; every other one goes to an
4122
4449
  `after_block` row - `would block: <class>`, `ok` or `no claim`, one per
4123
4450
  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."""
4451
+ claim or as a second reply in the shape rate. A host count with no refusal
4452
+ of this rule in the turn writes nothing: the host says some Stop hook
4453
+ blocked, not that this one did. `subagent` is a subagent's end (ADR 011):
4454
+ judged the same way, never refused, recorded as a `subagent_end` row with
4455
+ the same detail vocabulary, the host's subagent id in `agent` when it sent
4456
+ one, and no turn precondition. `parent` is the ledger that row goes to when
4457
+ the child keeps a ledger of its own (omp's task sessions); the child's own
4458
+ rows still judge it. Both return None. A reply counts as
4459
+ claiming only with `asserted_claims`: "Testler geçti mi?" leaves no
4460
+ `claim ok` row."""
4131
4461
  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):
4462
+ chain, rows = (_turn_refusals(session_id, rows) if turns
4463
+ else (list(filter(_blocked, rows)), rows))
4464
+ refused = [str(entry.get("detail"))[len("blocked: "):] for entry in chain]
4465
+ if after_block and not subagent and not refused:
4137
4466
  return None
4138
4467
  cls, reason = _stop_block(text, session_id, rows=rows, cwd=cwd,
4139
4468
  shape=not subagent)
4469
+ record_only = subagent or bool(
4470
+ (refused if turns else after_block)
4471
+ and (not reason or _record_only(refused, turns, after_block, cls)))
4140
4472
  key = _claim_key(text, turns)
4141
4473
  # Keyed like the claim row, so Cursor's re-run of the handler on a follow-up
4142
4474
  # does not count the same reply twice; above the early return, so a reply
@@ -4173,12 +4505,28 @@ def stop_reason(text, session_id, cwd=None, record_only=False,
4173
4505
  except Exception:
4174
4506
  if os.environ.get("TEZGAH_STOPSPEC_STRICT"):
4175
4507
  raise
4508
+ # A refusal is skipped only as the same verdict on the same reply: the same
4509
+ # reply text refused for a new class is the turn's next refusal, and
4510
+ # `_record_only` counts refusals by these rows. A row bound for a parent's
4511
+ # ledger is looked up there, so one child's end is one row.
4176
4512
  if not any(entry.get("kind") == kind and entry.get("id") == key
4177
- for entry in rows):
4513
+ and (entry.get("detail") == detail or not reason)
4514
+ and (not parent or entry.get("agent") == (str(agent) if agent else None))
4515
+ for entry in (turn_rows(parent) if parent else rows)):
4178
4516
  if cls == "no verify_ok" and not record_only:
4179
4517
  shape = dict(shape, cause=_no_pass_cause(rows))
4518
+ if reason:
4519
+ # the key omp's continuation turn will carry (`_turn_refusals`)
4520
+ shape = dict(shape, reask=_prompt_key(str(reason).strip()))
4180
4521
  if subagent and agent:
4181
4522
  shape = dict(shape, agent=str(agent))
4523
+ if subagent:
4524
+ # the child is never refused (ADR 011); the parent's Stop reads
4525
+ # this count (`_residual_block`), so an untyped item handed up
4526
+ # still owes a type before the user's turn ends
4527
+ handed = len(unblocked_items(text, rows))
4528
+ if handed:
4529
+ shape = dict(shape, residual=handed)
4182
4530
  if kind == "claim":
4183
4531
  # "harness drifted" rides on the claim as a record, read from the
4184
4532
  # mark the session start left; it never refuses the turn. Imported
@@ -4190,7 +4538,7 @@ def stop_reason(text, session_id, cwd=None, record_only=False,
4190
4538
  drifted = ""
4191
4539
  if drifted:
4192
4540
  shape = dict(shape, harness="drifted: " + drifted)
4193
- note(session_id, kind, detail, id=key, **shape)
4541
+ note(parent or session_id, kind, detail, id=key, **shape)
4194
4542
  return reason
4195
4543
 
4196
4544
 
@@ -4231,18 +4579,59 @@ WORK_KINDS = frozenset(STEP_KINDS) - {"verify_ok"}
4231
4579
  # that writes would excuse an unverified change.
4232
4580
  READ_ONLY_PROGRAMS = frozenset((
4233
4581
  "ls", "cat", "head", "tail", "wc", "pwd", "echo", "printf", "which", "stat",
4234
- "du", "df", "date", "whoami", "grep", "rg", "cd", "true"))
4582
+ "du", "df", "date", "whoami", "grep", "rg", "cd", "true", "find", "jq", "sort",
4583
+ "diff", "ps", "curl"))
4235
4584
  GIT_BOOKKEEPING = frozenset((
4236
4585
  "status", "log", "diff", "show", "add", "commit", "push", "fetch", "branch",
4237
4586
  "tag", "remote", "rev-parse", "describe", "shortlog", "blame", "ls-files"))
4587
+ # The git subcommands that only read: a turn made of these and READ_ONLY_PROGRAMS
4588
+ # owes no check at all (`_stop_block`, S4 finding 3). A command not on the lists
4589
+ # is work - an unknown program owes the check, a known one never excuses a write.
4590
+ GIT_READS = GIT_BOOKKEEPING - {"add", "commit", "push", "fetch", "branch", "tag",
4591
+ "remote"}
4592
+ # ssh's options that take the next word, so the host is read after them
4593
+ SSH_VALUE_OPTS = frozenset(("-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J",
4594
+ "-L", "-l", "-m", "-O", "-o", "-p", "-Q", "-R", "-S",
4595
+ "-W", "-w"))
4596
+ # sqlite3 arguments that write a file or run a script even on a read-only open:
4597
+ # a dot-command that writes, runs or reads one, an init script, a second
4598
+ # database, a vacuum into a file
4599
+ SQLITE_WRITE = re.compile(r"^\s*\.(?:output|once|shell|system|save|backup|import|"
4600
+ r"excel|clone|log|trace|cd|open|read)\b|^-{1,2}init\b|"
4601
+ r"\battach\b|\bvacuum\b", re.I | re.M)
4602
+ # sqlite3's options that take the next word, so the database is read after them
4603
+ SQLITE_VALUE_OPTS = frozenset(("cmd", "init", "separator", "newline", "nullvalue",
4604
+ "vfs", "lookaside", "pagecache", "mmap", "heap",
4605
+ "maxsize", "escape"))
4606
+ # A script argument of sed or awk that writes a file or runs a command: sed's
4607
+ # `w`/`W`/`e` commands and flags, awk's redirect, pipe, system() and getline.
4608
+ SCRIPT_WRITE = {"sed": re.compile(r"(?:^|[;}/\n$\d])\s*[wWe]\b"),
4609
+ "awk": re.compile(r"[>|]|\b(?:system|getline)\b"),
4610
+ "gawk": re.compile(r"[>|]|\b(?:system|getline)\b")}
4611
+ # The read verbs of a program whose subcommand decides what it does
4612
+ SUBCOMMAND_READS = {"gh": (("pr", "view"), ("pr", "list"), ("issue", "view"),
4613
+ ("issue", "list")),
4614
+ "codegraph": (("explore",), ("callers",), ("callees",),
4615
+ ("impact",), ("search",), ("node",), ("files",),
4616
+ ("status",))}
4238
4617
  # 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",)}
4618
+ # all (review F6), as a pattern read at an argument's start: git's `--output`
4619
+ # and external diff/textconv drivers, ripgrep's `--pre`, find's actions, a
4620
+ # short-option cluster holding sort's `-o`, sed's `-i`/`-f`, awk's `-i`/`-f`,
4621
+ # and curl's writes to a file and non-GET requests. `tree` and `file` left the
4622
+ # list instead of growing one here: tree writes through clustered short options
4623
+ # (`-fo out`) and `-R -H`, file through `-C` (review N4).
4624
+ WRITING_OPTIONS = {
4625
+ "git": re.compile(r"--(?:output|ext-diff|textconv)"),
4626
+ "rg": re.compile(r"--pre"),
4627
+ "find": re.compile(r"-(?:delete|exec|execdir|ok|okdir|fprint0?|fprintf|fls)$"),
4628
+ "sort": re.compile(r"-[^-]*o|--output"),
4629
+ "sed": re.compile(r"-[^-]*[if]|--(?:in-place|file)"),
4630
+ "awk": re.compile(r"-[^-]*[if]|--(?:include|file|load)"),
4631
+ "gawk": re.compile(r"-[^-]*[if]|--(?:include|file|load|inplace)"),
4632
+ "curl": re.compile(r"-[^-]*[oOdTFKXcD]|--(?:output|remote-name|data|upload-file|"
4633
+ r"form|json|request|config|cookie-jar|dump-header|trace|"
4634
+ r"stderr|libcurl|etag-save|hsts|alt-svc|create-dirs)")}
4246
4635
  # git global options that set configuration or the program path, and so can
4247
4636
  # name a program a read subcommand then runs (`git -c diff.external=./x diff`)
4248
4637
  GIT_CONFIG_OPTS = ("-c", "--config-env", "--exec-path")
@@ -4324,12 +4713,18 @@ def _shell_read(cmd):
4324
4713
  return ">" in text or "<(" in text, "".join(out).replace("<", " ")
4325
4714
 
4326
4715
 
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.
4716
+ def _bookkeeping_command(cmd, git=GIT_BOOKKEEPING):
4717
+ """True when every simple command of `cmd` reads, or runs a git subcommand
4718
+ in `git` (VCS bookkeeping that changes no working-tree file by default,
4719
+ GIT_READS for a pure read). A redirect, a substitution, an env assignment
4720
+ in front of a program, a git config override and an option that makes a
4721
+ reader write (`WRITING_OPTIONS`) are never bookkeeping: each can write or
4722
+ run something this reader does not follow. `sed` and `awk` read with no
4723
+ writing script (`SCRIPT_WRITE`), `uniq` with one operand, `gh` and
4724
+ `codegraph` with a read verb (`SUBCOMMAND_READS`). `sqlite3` reads with a
4725
+ read-only open (the option, or `mode=ro` in the database URI) and no
4726
+ writing dot-command or stdin (`SQLITE_WRITE`), `ssh` with a remote command
4727
+ that is itself one of these reads.
4333
4728
 
4334
4729
  ponytail: `git commit` and `git push` run the repository's hooks, and a
4335
4730
  pre-commit autofixer can rewrite files the row never names. They stay on
@@ -4351,10 +4746,45 @@ def _bookkeeping_command(cmd):
4351
4746
  if "/" in words[i]:
4352
4747
  return False
4353
4748
  program, args = words[i], words[i + 1:]
4354
- if any(a.startswith(WRITING_OPTIONS.get(program, ("\0",))) for a in args):
4749
+ writes = WRITING_OPTIONS.get(program)
4750
+ if writes and any(writes.match(a) for a in args):
4355
4751
  return False
4356
4752
  if program in READ_ONLY_PROGRAMS:
4357
4753
  continue
4754
+ plain = [a for a in args if not a.startswith("-")]
4755
+ if program in SCRIPT_WRITE:
4756
+ if any(SCRIPT_WRITE[program].search(a) for a in plain):
4757
+ return False
4758
+ continue
4759
+ if program == "uniq":
4760
+ # `uniq IN OUT` writes its second operand
4761
+ if len(plain) > 1:
4762
+ return False
4763
+ continue
4764
+ if program in SUBCOMMAND_READS:
4765
+ if not any(tuple(plain[:len(verb)]) == verb
4766
+ for verb in SUBCOMMAND_READS[program]):
4767
+ return False
4768
+ continue
4769
+ if program == "sqlite3":
4770
+ j, ro = 0, False
4771
+ while j < len(args) and args[j].startswith("-"):
4772
+ ro = ro or args[j].lstrip("-") == "readonly"
4773
+ j += 2 if args[j].lstrip("-") in SQLITE_VALUE_OPTS else 1
4774
+ db = args[j] if j < len(args) else ""
4775
+ # read-only from the option or the database URI itself, never
4776
+ # from words in the SQL
4777
+ ro = ro or (db.startswith("file:") and "mode=ro" in db.partition("?")[2])
4778
+ if "<" in raw or not ro or any(SQLITE_WRITE.search(a) for a in args):
4779
+ return False
4780
+ continue
4781
+ if program == "ssh":
4782
+ j = 0
4783
+ while j < len(args) and args[j].startswith("-"):
4784
+ j += 2 if args[j][:2] in SSH_VALUE_OPTS and len(args[j]) == 2 else 1
4785
+ if j + 1 >= len(args) or not _bookkeeping_command(" ".join(args[j + 1:]), git):
4786
+ return False
4787
+ continue
4358
4788
  if program != "git":
4359
4789
  return False
4360
4790
  j = 0
@@ -4362,14 +4792,15 @@ def _bookkeeping_command(cmd):
4362
4792
  if args[j].startswith(GIT_CONFIG_OPTS):
4363
4793
  return False
4364
4794
  j += 2 if args[j] in GIT_VALUE_OPTS else 1
4365
- if j >= len(args) or args[j].lower() not in GIT_BOOKKEEPING:
4795
+ if j >= len(args) or args[j].lower() not in git:
4366
4796
  return False
4367
4797
  return bool(segs)
4368
4798
 
4369
4799
 
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.
4800
+ def _bookkeeping_turn(rows, git=GIT_BOOKKEEPING):
4801
+ """True when every work row in `rows` is a shell call that read or ran a
4802
+ git subcommand in `git` (`_bookkeeping_command`) and was not seen to
4803
+ change a file.
4373
4804
 
4374
4805
  A detail of DETAIL_MAX characters or more is never bookkeeping: the ledger
4375
4806
  cut it there, and the command after the cut is unread (review F1). Nor is
@@ -4380,7 +4811,7 @@ def _bookkeeping_turn(rows):
4380
4811
  r.get("kind") == "run" and not _change_row(r)
4381
4812
  and len(str(r.get("detail") or "")) < DETAIL_MAX
4382
4813
  and "[redacted:" not in str(r.get("detail") or "")
4383
- and _bookkeeping_command(r.get("detail")) for r in steps)
4814
+ and _bookkeeping_command(r.get("detail"), git) for r in steps)
4384
4815
 
4385
4816
 
4386
4817
  def _settled(rows):
@@ -4405,13 +4836,14 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4405
4836
  passed before the newest write that changed the tree, or a turn that
4406
4837
  recorded a step and has no passing check - that last half is the
4407
4838
  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.
4839
+ description is refused too. An explicit 'doğrulanmadı' clears a class whose
4840
+ repair is a check, and only after a check that ran and failed since the
4841
+ newest change (`_admitted`, plan 080): the word alone ended every turn.
4410
4842
 
4411
4843
  The shape is judged before any evidence is read: it is a rule about how the
4412
4844
  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.
4845
+ repair that depends on the ledger, and is not cleared by the admission -
4846
+ "doğrulanmadı" excuses an unverified claim, not a sign-off.
4415
4847
 
4416
4848
  Branch order is the reason classes' contract: a new branch goes after the
4417
4849
  ones it overlaps, so it cannot swallow their class - the partial-failure
@@ -4427,12 +4859,21 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4427
4859
  above it carries the one exemption - an advice-only turn is exactly the
4428
4860
  shape that stated both of the claims this class was written for.
4429
4861
 
4862
+ `residual work` (plan 080) is asked after all of them, at every exit that
4863
+ would end the turn (`_residual_block`): a turn that did work and ends on a
4864
+ list of what is left, with an item no blocker accounts for. It never takes
4865
+ a turn another class refused. `stopped slice` (plan 081) is asked at the
4866
+ same exits after it: a slice this session spawned that the journal holds as
4867
+ stopped, worked turn or not.
4868
+
4430
4869
  "Evidence tampered" is the first evidence class: a `ledger_damage` row in
4431
4870
  the turn (`_parse`) means a line of the ledger is not a row, and no fold
4432
4871
  over it can carry a claim - corrupting a `verify_fail` row would otherwise
4433
4872
  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.
4873
+ about the reply, not the ledger) and before the admission: a done or tested
4874
+ claim on a damaged ledger is refused before the admission is read, since
4875
+ that admission now rests on the ledger's own rows (plan 080). A reply with
4876
+ no claim word is not this class's, damaged ledger or not.
4436
4877
 
4437
4878
  `rows` is this turn's own rows, read once by `stop_reason`: the fold is
4438
4879
  scoped the way `_partial_state`'s already was, and the Stop path still reads
@@ -4447,7 +4888,7 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4447
4888
  if os.environ.get("TEZGAH_STOPSPEC_STRICT"):
4448
4889
  import tezgah_stopspec
4449
4890
  tezgah_stopspec.check(verdict[0], text, session_id, rows, cwd, shape)
4450
- return verdict
4891
+ return (verdict[0], verdict[1] and _said(verdict[1]))
4451
4892
  t = str(text or "")
4452
4893
  shaped = _shape_block(t, cwd) if shape else (None, None)
4453
4894
  if shaped[0]:
@@ -4455,19 +4896,19 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4455
4896
  # the raw vocabulary, not `claims`: what a damaged ledger could carry is any
4456
4897
  # claim word, an admitted conflict included (plan 067 leaves verdicts alone)
4457
4898
  done, verified = bool(DONE.search(t)), bool(VERIFIED.search(t))
4458
- if NEGATED.search(t):
4459
- return (None, None)
4460
4899
  rows = events(session_id) if rows is None else rows
4461
4900
  if (done or verified) and any(r.get("kind") == DAMAGE_KIND for r in rows):
4462
4901
  return ("evidence tampered", TAMPERED)
4463
4902
  if (done or verified) and any(r.get(ORPHAN) for r in rows):
4464
4903
  return ("evidence tampered", ORPHANED)
4904
+ fold = _admitted(fold, t)
4465
4905
  # The trigger is the turn's own evidence, not its words. The claim vocabulary
4466
4906
  # below catches a claim-shaped reply; it missed the same unfounded state
4467
4907
  # stated as a description ("the parser is wired up now"), which is what E2
4468
4908
  # measured at 0 refused of 10 replies. A turn that recorded a step - an edit,
4469
4909
  # 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.
4910
+ # says; the reply's own "doğrulanmadı" clears it only after a check that ran
4911
+ # and failed since the newest change (`_admitted`, above).
4471
4912
  # `run`/`verify_fail` are steps here for the same reason: work the turn did
4472
4913
  # and left unverified is exactly the state this refuses.
4473
4914
  # `interrupted` too: the call was stopped, so the turn did that much work and
@@ -4480,7 +4921,13 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4480
4921
  if not worked and any(_prose_edit(r) for r in rows) and (
4481
4922
  verified or any(TESTED_WORD.search(m.group(0)) for m in DONE.finditer(t))):
4482
4923
  worked = {"edit"}
4483
- if not worked:
4924
+ # A turn whose steps are only reads - every simple command a known reader
4925
+ # or a git read (`_bookkeeping_turn` with GIT_READS), no change row - is
4926
+ # judged like a turn that did no work (S4 finding 3): a `git status`, a
4927
+ # `tail` or a read-only query owes no check. Its claim words are read the
4928
+ # same way, and a claim is judged against the session's earlier turns below.
4929
+ quiet = _bookkeeping_turn(rows, GIT_READS)
4930
+ if not worked or quiet:
4484
4931
  # With no work, the words are the whole trigger, so a question or a
4485
4932
  # negation is not read as the claim it names (`asserted_claims`).
4486
4933
  done, verified = asserted_claims(t)
@@ -4504,10 +4951,12 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4504
4951
  # exit below - "nothing was done and nothing was claimed" - is exactly the
4505
4952
  # shape that used to leave such a claim unjudged.
4506
4953
  external = _external_claim(t)
4507
- if not worked and not (done or verified) and not external:
4508
- return (None, None)
4954
+ if (not worked or quiet) and not (done or verified) and not external:
4955
+ return _residual_block(t, rows, shape, session_id=session_id, cwd=cwd)
4509
4956
  if lost:
4510
- return fold(rows, worked | {BEGAN_KIND}, external)
4957
+ return _residual_block(t, rows, shape,
4958
+ fold(rows, worked | {BEGAN_KIND}, external),
4959
+ session_id, cwd)
4511
4960
  # Two shapes the turn's own rows cannot judge, so they are judged against
4512
4961
  # the session's (the contract says "no successful check recorded in the
4513
4962
  # session"). Read only for these two, so every other turn still pays for
@@ -4530,8 +4979,10 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4530
4979
  # the turn fold below, which asks for a fresh check.
4531
4980
  srows = events(session_id)
4532
4981
  if not external and _settled(srows):
4533
- return fold(_before_turn(srows), True, None, "this session")
4534
- elif not worked and (done or verified):
4982
+ return _residual_block(t, rows, shape, fold(
4983
+ _before_turn(srows), True, None, "this session"),
4984
+ session_id, cwd)
4985
+ if (not worked or quiet) and (done or verified):
4535
4986
  # A claim in a turn that did nothing: "Tamamlandı, tüm testler
4536
4987
  # geçti" after a turn that edited and ended "doğrulanmadı" was
4537
4988
  # allowed on all five Stop hosts (audit INT-01 / M-2).
@@ -4541,7 +4992,15 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4541
4992
  "this session")
4542
4993
  if refused[0]:
4543
4994
  return refused
4544
- return fold(rows, worked, external)
4995
+ judged = rows
4996
+ if quiet:
4997
+ # the fold reads work off the rows too (the spec's `W` atom), so a turn
4998
+ # of reads hands it none - only an external read, the one thing such a
4999
+ # turn can prove
5000
+ judged, worked = [r for r in rows if not _work_row(r) or (
5001
+ external and _external_read_row([r]) == 0)], set()
5002
+ return _residual_block(t, rows, shape, fold(judged, worked, external),
5003
+ session_id, cwd)
4545
5004
 
4546
5005
 
4547
5006
  def _before_turn(rows):
@@ -4578,7 +5037,8 @@ def _evidence_block(rows, worked, external, where="this turn"):
4578
5037
  return ("check failed",
4579
5038
  "A check failed in %s and the reply claims success. "
4580
5039
  "Report the failure with its exact error line, or fix it and "
4581
- "re-run; do not describe a failed check as passing." % where)
5040
+ "re-run; do not describe a failed check as passing. %s."
5041
+ % (where, ADMISSION))
4582
5042
  # a failure the turn never resolved: the newest check is not a pass, so an
4583
5043
  # earlier green run over a different command does not license the claim
4584
5044
  state = _partial_state(rows)
@@ -4588,15 +5048,16 @@ def _evidence_block(rows, worked, external, where="this turn"):
4588
5048
  "an earlier green run does not cover it. Report that failure "
4589
5049
  "with its exact error line, or fix it and re-run the check; do "
4590
5050
  "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ı\"."
5051
+ "stopping on the failure, say what is still broken; %s."
4593
5052
  % (_failed_check(rows),
4594
- "after %s's edits" % where if state["edited"] else "in " + where))
5053
+ "after %s's edits" % where if state["edited"] else "in " + where,
5054
+ ADMISSION))
4595
5055
  # A passing check licenses the claim only when it is newer than the newest
4596
5056
  # write the gate saw change the tree: a green run over the previous revision
4597
5057
  # 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
5058
+ # (the check's position, and `changed` on the write). The admission is the
5059
+ # reply's own "doğrulanmadı" after a check that failed since that write
5060
+ # (`_admitted`). The pass has to be one
4600
5061
  # run in the newest change's own repository (`_last_pass`'s `repo`).
4601
5062
  last_change = _last_change(rows)
4602
5063
  last_pass = _last_pass(rows, _change_repo(rows, last_change))
@@ -4621,8 +5082,11 @@ def _evidence_block(rows, worked, external, where="this turn"):
4621
5082
  "its output, or read the rendered screen (`analyze-app`: the "
4622
5083
  "accessibility/DOM tree, a screenshot at the widths in scope) "
4623
5084
  "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"))
5085
+ "what a person sees, and \"{UNVERIFIED}\" does not stand in for "
5086
+ "the screen; if nothing can render it here, write "
5087
+ "\"{UNVERIFIED}\" and name it under \"{BLOCKERS_HEADING}\" as {ENV} "
5088
+ "with the command that failed."
5089
+ % (where, shown or "a UI file"))
4626
5090
  if ui_write >= 0:
4627
5091
  # The floor, asked of a component turn on top of the screen proof: a
4628
5092
  # screenshot or a rendered check says what the thing looks like and
@@ -4644,7 +5108,9 @@ def _evidence_block(rows, worked, external, where="this turn"):
4644
5108
  "(`tezgah-design derive` writes the contract when the repo "
4645
5109
  "has none; the measurement is the per-component styles and "
4646
5110
  "states the analyze-app loop produces) and report every "
4647
- "violation it prints, or mark the claim \"doğrulanmadı\"."
5111
+ "violation it prints; if the check cannot run here, write "
5112
+ "\"{UNVERIFIED}\" and name it under \"{BLOCKERS_HEADING}\" as {ENV} "
5113
+ "with the command that failed."
4648
5114
  % (where, shown or "a component file"))
4649
5115
  # A screen proof stands where a unit pass stands for the rest of this fold,
4650
5116
  # so a UI turn whose UI work is proven is not asked for a check it already
@@ -4670,12 +5136,12 @@ def _evidence_block(rows, worked, external, where="this turn"):
4670
5136
  return ("stale evidence",
4671
5137
  "Stale evidence: the newest check that passed ran before %s %s "
4672
5138
  "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ı\". "
5139
+ "one this reply is about. Run the tests of the files written since "
5140
+ "(`python3 tests/impacted.py --run <paths>` where the repo has "
5141
+ "it; not the whole suite) and report its output; %s. "
4676
5142
  "A green run over the previous revision does not cover this one."
4677
5143
  % (shown or "a file this session wrote",
4678
- "was" if len(names) <= 1 else "were"))
5144
+ "was" if len(names) <= 1 else "were", ADMISSION))
4679
5145
  elif worked and _last_pass(rows) > last_change:
4680
5146
  # a pass newer than the change exists, only in another repository: the
4681
5147
  # class stays (ledger continuity), the text names the real reason
@@ -4683,34 +5149,39 @@ def _evidence_block(rows, worked, external, where="this turn"):
4683
5149
  "%s changed %s, and the check that passed after it ran in "
4684
5150
  "another repository (%s), so it says nothing about this one. "
4685
5151
  "Run the check in the repository you changed and report its "
4686
- "output, or mark the claim \"doğrulanmadı\"."
5152
+ "output; %s."
4687
5153
  % (where.capitalize(), _change_repo(rows, last_change),
4688
5154
  rows[max(i for i, r in enumerate(rows)
4689
- if passing_check(r))].get("repo")))
5155
+ if passing_check(r))].get("repo"), ADMISSION))
4690
5156
  elif worked:
4691
5157
  return ("no verify_ok",
4692
5158
  "%s did work (edits or commands) and no check ran "
4693
5159
  "successfully in it (nothing recorded as verify_ok with a "
4694
5160
  "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 "
5161
+ "calling it done, complete or verified. Run the tests of the "
5162
+ "files written (`python3 tests/impacted.py --run <paths>` where "
5163
+ "the repo has it; not the whole suite) and report its output; "
5164
+ "%s. Do not "
4699
5165
  "describe a check you did not run as if it ran."
4700
- % where.capitalize())
5166
+ % (where.capitalize(), ADMISSION))
4701
5167
  # Last, so none of the classes above loses its turn to it: every shape this
4702
5168
  # one refuses is a shape the fold was about to allow. That is the whole
4703
5169
  # ordering rule of this function - the branch is asked only where the answer
4704
5170
  # would otherwise be "this turn may end".
4705
5171
  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)))
5172
+ return ("no external read", _external_text(external, where))
5173
+ return (None, None)
5174
+
5175
+
5176
+ def _external_text(external, where="this turn"):
5177
+ """The `no external read` refusal text, for the fold and for `_admitted`."""
5178
+ return ("The reply states the state of a system tezgah does not own (%s) "
5179
+ "and no read of that system ran in %s. Only the system "
5180
+ "itself knows: a local client's cache, a stale checkout and a "
5181
+ "memory of what used to be published all answer this wrongly. Run "
5182
+ "the read and report its output - %s; a read that fails is an "
5183
+ "answer too, quote it." % (cut(external.strip(), 80), where,
5184
+ _external_command(external)))
4714
5185
  return (None, None)
4715
5186
 
4716
5187
 
@@ -4748,18 +5219,13 @@ EXTERNAL_SYSTEM = re.compile(
4748
5219
  r"\b(?:npm|yarn|pnpm|pip|pypi|py ?pi|crates(?:\.io)?|rubygems|gem|"
4749
5220
  r"brew|homebrew|formula|tap|dist-tag|tarball|registry|releases?|tags?|"
4750
5221
  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)
5222
+ EXTERNAL_STATE = re.compile(r"\b(?:%s)\b" % alts("external_state"), re.I)
4755
5223
  # The status of a run, which pairs with a CI subject alone: "the workflow is red"
4756
5224
  # and "CI passed" are claims about a system tezgah does not own, while the same
4757
5225
  # words beside `release` are prose ("Suite green, release ready"), which is what
4758
5226
  # the split is for.
4759
5227
  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)
5228
+ EXTERNAL_CI_STATE = re.compile(r"\b(?:%s)\b" % alts("external_ci_state"), re.I)
4763
5229
  # How far apart, on one line, the two halves of a claim may sit. 45 characters
4764
5230
  # holds every form the incident took ("npm 0.22.0 yayımlanmadı" is 12 apart,
4765
5231
  # "the registry has no such version published" is 30) and drops the long report
@@ -4771,10 +5237,8 @@ EXTERNAL_GAP = 45
4771
5237
  # numbers in prose. A two-part number was the single largest source of false
4772
5238
  # positives in the measurement above.
4773
5239
  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)
5240
+ r"\bv\d+\.\d+\.\d+[^\n]{{0,40}}?\b(?:{0})\b|\b(?:{0})\b[^\n]{{0,40}}?v\d+\.\d+\.\d+"
5241
+ .format(alts("publish_state")), re.I)
4778
5242
  # The read that answers it, at a command position on the masked text - the
4779
5243
  # convention `UI_CHECK` and `NETWORK_READ` follow - so a reply that merely names
4780
5244
  # `npm view`, and the search whose pattern is that name, are not reads of
@@ -4886,6 +5350,892 @@ def _external_read_row(rows):
4886
5350
  return -1
4887
5351
 
4888
5352
 
5353
+ def _failed_since_change(rows):
5354
+ """True when a check ran and failed after the newest write that changed the
5355
+ tree (`_last_change`): the attempt the admission rests on. A failure before
5356
+ that write is about the tree the write replaced."""
5357
+ return any(r.get("kind") == "verify_fail" for r in rows[_last_change(rows) + 1:])
5358
+
5359
+
5360
+ def _proof_attempt_failed(rows):
5361
+ """True when the proof `no ui_ok` asks of `rows` was attempted and failed
5362
+ after the write it is about: a check that sees the screen (`UI_CHECK`) after
5363
+ the newest UI write - the design check measures that rendered screen, so a
5364
+ screen that did not render leaves it nothing to run on - or, with the screen
5365
+ proven, the design check (`DESIGN_CHECK`) after the newest component write.
5366
+ A failed screen check is the attempt the admission rests on there; a failed
5367
+ unit check is not."""
5368
+ def failed_after(i, check):
5369
+ return any(r.get("kind") == "verify_fail"
5370
+ and check.search(mask(str(r.get("detail") or "")))
5371
+ for r in rows[i + 1:])
5372
+ write, proof = _ui_evidence(rows)
5373
+ if write >= 0 and proof <= write:
5374
+ return failed_after(write, UI_CHECK)
5375
+ return failed_after(_design_evidence(rows)[0], DESIGN_CHECK)
5376
+
5377
+
5378
+ def _prose(text):
5379
+ """`text` with its fenced blocks and inline code blanked: a test selector
5380
+ (`pytest -k unverified`), a file name (`DECISION.md`) or a pasted log line is
5381
+ no word of the reply's own."""
5382
+ return CODE_SPAN.sub(" ", FENCED_BODY.sub(" ", str(text or "")))
5383
+
5384
+
5385
+ def _admitted(fold, text):
5386
+ """`fold` with the admission applied (plan 080). A verdict in
5387
+ ADMITTED_CLASSES is cleared when the reply's prose admits the work is
5388
+ unverified (NEGATED) and the rows that fold judged hold a check that failed
5389
+ since their newest change. The failure is the part admitted, and nothing
5390
+ else: the fold is asked again without the failed checks, so a screen or
5391
+ design proof the turn owes still comes back (`no ui_ok`) unless that proof
5392
+ is the check that ran and failed (`_proof_attempt_failed`), and an external
5393
+ claim with no read of its system still owes the read (`no external read`) -
5394
+ otherwise a failed unit check would end a turn its passing twin cannot.
5395
+ Wrapped around whichever fold the selector was given, so the live rule and
5396
+ the temporal spec read the admission the same way."""
5397
+ if not NEGATED.search(_prose(text)):
5398
+ return fold
5399
+
5400
+ def admitted(rows, worked, external, *where):
5401
+ verdict = fold(rows, worked, external, *where)
5402
+ if not verdict[0] or not _failed_since_change(rows):
5403
+ return verdict
5404
+ owed = fold([r for r in rows if r.get("kind") != "verify_fail"],
5405
+ worked, external, *where)
5406
+ if owed[0] and owed[0] not in ADMITTED_CLASSES and not (
5407
+ owed[0] == "no ui_ok" and _proof_attempt_failed(rows)):
5408
+ return owed
5409
+ if external is not None and _external_read_row(rows) < 0:
5410
+ return ("no external read", _external_text(external, *where))
5411
+ return (None, None)
5412
+ return admitted
5413
+
5414
+
5415
+ # The eleventh Stop class, `residual work` (plan 080): a turn that did work and
5416
+ # ends on a list of what is left - "Açık kalanlar", "Doğrulanmadı:", "Sıradaki
5417
+ # adım" - instead of doing it. Before the class, 503 of 1876 final replies in
5418
+ # the owner's omp and Claude transcripts carried such a label at a line start,
5419
+ # 467 of them in a turn that called a non-read tool: a section that reported
5420
+ # the work was the cheapest end of a turn. An item stays only when something
5421
+ # accounts for it: a KARAR or GERİ ALINAMAZ token (a choice or a consent, which
5422
+ # no ledger row can prove), or a YETKİ or ORTAM token together with a quote of a
5423
+ # command that failed in the turn - the type, and the attempt and its error the
5424
+ # request asks for; either alone keeps nothing, so an untyped "Doğrulanmadı:
5425
+ # `pytest` ..." is refused like any other item. Asked only in
5426
+ # a turn that did work - a spec, an answer or a plan proposal lists
5427
+ # assumptions, non-goals and next steps as its content - and only at an exit
5428
+ # that would end the turn, so it never takes a turn another class refused.
5429
+ # Labels the other rules ask for stay out of the list: a spec's assumptions and
5430
+ # alternatives, a measurement's limits ("Ölçüm sınırı"), risks ("Riskler",
5431
+ # "Regresyon riski": the reply keeps its risks) and a bare recommendation
5432
+ # ("Önerim"); suggested improvements are work left and stay in. ponytail: a word
5433
+ # list read at line starts, not a reader of what an item means - a doable item
5434
+ # typed KARAR passes, which the question endings `tezgah-gate replay` counts are
5435
+ # there to watch.
5436
+ # The first-person negatives ("Yapamadım", "Bakmadıklarım", "Test etmedim"),
5437
+ # the passive stems "kontrol edil-", "incelen-", "ölçül-", "çözül-", "denen-" and
5438
+ # the English forms after them were added from the S4 corpus (finding 4).
5439
+ RESIDUAL_LABEL = re.compile(r"(?:%s)" % alts("residual_label"))
5440
+ # "Kalan" is also an adjective ("Kalan riskler", "Kalan süre", "Ayakta kalan
5441
+ # tek şey"): it opens a list only as the label's first word and on the work it
5442
+ # names - "Kalan:", "Kalanlar", "Kalan iş", "Kalan adımlar", "Kalan tek şey".
5443
+ RESIDUAL_FIRST = alts("residual_first")
5444
+ # At most three words may lead any other stem: "Bu turda açık kalanlar", "Henüz
5445
+ # yapılmayanlar", "Bu turda henüz bakılmayanlar".
5446
+ RESIDUAL_AT = re.compile(r"(?:%s)|(?:[^\W\d][\w'’-]*\s+){0,3}(?:%s)"
5447
+ % (RESIDUAL_FIRST, RESIDUAL_LABEL.pattern))
5448
+ # A label whose own words close the list: a claim word (`asserted_claims`), or
5449
+ # a completion verb or participle the claim vocabulary does not carry ("Eksik
5450
+ # kapandı", "Düzeltilen eksik", "Doğrulanmayan kalmadı", "Eksik olan kanıt artık
5451
+ # var"). "Kısmen ..." stays open.
5452
+ LABEL_DONE = re.compile(r"\b(?:%s)\b" % alts("label_done"))
5453
+ PARTIAL = re.compile(r"(?:%s)\b" % alts("partial"))
5454
+ # A short label that says nothing is left - it ends on "yok"/"none" or opens on
5455
+ # "no"/"none" - is no list ("Eksik yok"); in a longer one, or after a comma, the
5456
+ # "yok" is about something else ("Doğrulanmadı, kanıt yok"). A word in a
5457
+ # parenthesis is a comment on the label.
5458
+ NOTHING_LEFT = re.compile(r"(?:%s)\W*$|^(?:%s)\b"
5459
+ % (alts("nothing_left"), alts("nothing_lead")))
5460
+ NOTHING_SHORT = 4
5461
+ PARENTHESIS = re.compile(r"\([^)]*\)")
5462
+ # An item that is only that word, or opens on it before a stop ("yok, iş
5463
+ # bitti."), or is a bare zero, is no item; a "yok" inside an item ("playwright
5464
+ # yok") is content and is judged like the rest.
5465
+ EMPTY_ITEM = re.compile(r"\W*(?:(?:%s)(?:\s*[.,;:!—–-][^\n]*)?|0)\W*"
5466
+ % alts("empty_item"), re.I)
5467
+ # The blocker tokens, as the capitalised token the contract names them by, in
5468
+ # every pack's spelling (`blockers.<kind>.pattern`: DECISION and KARAR alike), a
5469
+ # suffix allowed ("KARARI senin"); a token negated by the next word ("KARAR
5470
+ # gerekmiyor") is none, and code is blanked first (`_prose`).
5471
+ # BLOCKER_SAID needs no ledger row: a choice or a consent leaves none to check.
5472
+ # BLOCKER_TRIED is the attempt that failed - it counts only beside the error
5473
+ # its type names (ENV_ERROR for the env token, ACCESS_ERROR for the access one,
5474
+ # read off the match's group name) and its ledger evidence: a quote of a
5475
+ # command that failed in the turn (`_quotes_failure`), or a spawn call of the
5476
+ # turn the host answered with a provider failure (`_spawn_failed`: a 429, a
5477
+ # quota, a credential).
5478
+
5479
+
5480
+ def _tokens(kind):
5481
+ """Every pack's spelling of blocker `kind`, as one alternation."""
5482
+ return "|".join(pack_values("blockers", kind, "pattern")) or "(?!)"
5483
+
5484
+
5485
+ BLOCKER_NOT = r"(?!\s+(?:%s))" % alts("blockers", "said_not")
5486
+ BLOCKER_SAID = re.compile(r"\b(?:%s|%s)[A-ZÇĞİIÖŞÜ]*\b"
5487
+ % (_tokens("decision"), _tokens("irreversible")) + BLOCKER_NOT)
5488
+ # BLOCKER_SAID's consent half: a GERİ ALINAMAZ item needs no options.
5489
+ IRREVERSIBLE_SAID = re.compile(BLOCKER_SAID.pattern.replace(_tokens("decision") + "|", "", 1))
5490
+ # After the access or env token, "yok" and "not" describe the blocker ("YETKİ
5491
+ # yok", "ENV not set"), so only a pack's `tried_not` words negate them; the
5492
+ # suffix is read on the stems a pack spells with one, so ACCESSIBILITY or
5493
+ # ENVELOPE is no type, while the plural ENVIRONMENTS is.
5494
+ BLOCKER_TRIED = re.compile(r"(?:\b(?P<access>%s)|\b(?P<env>%s))\b(?!\s+(?:%s))"
5495
+ % (_tokens("access"), _tokens("env"),
5496
+ alts("blockers", "tried_not")))
5497
+ # The word after the token that says the grant or tool is absent ("YETKİ yok").
5498
+ BLOCKER_ABSENT = r"(?:\s+(?:%s)\b)?" % alts("blockers", "absent")
5499
+ ENV_ERROR = re.compile(alts("env_error"), re.I)
5500
+ ACCESS_ERROR = re.compile(USER_ERROR.pattern + "|" + alts("access_error"), re.I)
5501
+ # A spawn call's failure classes that are the provider's, not the brief's: a
5502
+ # rate limit or a timeout (`transient`) and a quota or a credential (`user`).
5503
+ SPAWN_FAIL_CLASSES = frozenset(("transient", "user"))
5504
+ # The words that tie an item to a spawn call: only such an item borrows a
5505
+ # failed spawn's row as its evidence (`_tried_holds`).
5506
+ SPAWN_WORDS = re.compile(alts("spawn_words"))
5507
+ # A search that matched nothing exits 1 and failed nothing (`_failed_commands`).
5508
+ NO_MATCH_PROGRAMS = frozenset(("grep", "egrep", "fgrep", "rg", "ag"))
5509
+ LIST_MARK = re.compile(r"\s*(?:\d+[.)]|[-*+])\s+")
5510
+ QUOTE_MARK = re.compile(r"^(\s*)>\s?")
5511
+ LEAD_SYMBOLS = re.compile(r"^[^\w#*_`]+")
5512
+ CODE_SPAN = re.compile(r"`([^`\n]+)`")
5513
+ FENCED_BODY = re.compile(r"^\s*(?:```|~~~)[^\n]*\n(.*?)^\s*(?:```|~~~)", re.M | re.S)
5514
+ COMMAND_SPLIT = re.compile(r"&&|\|\||;|\|")
5515
+ # The refusal leads and blocker tokens are the configured language's
5516
+ # (`{RESIDUAL_LEAD}`, `{DECISION}`..., filled by `_said`): the owner's tr pack
5517
+ # opens in Turkish because Claude Code shows the refusal's first line to the
5518
+ # user; the canonical English pack adds no lead.
5519
+ RESIDUAL_WORK = (
5520
+ "{RESIDUAL_LEAD}The reply lists "
5521
+ "work that is left (%s) at the end of a turn that did work. Do each item "
5522
+ "now with the tools you have: a local merge of a branch this session made "
5523
+ "and the removal of its worktree are such steps. A step that is outward or "
5524
+ "cannot be undone (a push or a release with no grant from the user this "
5525
+ "session, a force push, a delete of what the session did not make) is never "
5526
+ "taken to clear this list - it is {IRREVERSIBLE}, and you ask. An item may "
5527
+ "stay only as one of four "
5528
+ "blockers, named in capitals: {DECISION} (a choice only the user can make, "
5529
+ "with its options and a \"{RECOMMENDED}:\" default - never \"which first\" or "
5530
+ "\"shall I go on\"), {ACCESS} (a credential or grant "
5531
+ "no tool supplies), {IRREVERSIBLE} (needs consent), {ENV} (a tool or "
5532
+ "environment is missing); a {ACCESS} or {ENV} item quotes, in backticks, the "
5533
+ "command that failed in this turn and its error - a quote with no type, "
5534
+ "or a type with no quote, is not one. \"Out of "
5535
+ "scope\", \"later\", \"time\" and \"could be added\" are not blockers. Report "
5536
+ "what is left under one heading, \"{BLOCKERS_HEADING}\", at most three lines: item - "
5537
+ "blocker - evidence - what unblocks it.")
5538
+ # The `stopped slice` class (plan 081): a subagent slice this session spawned
5539
+ # ended stopped - a quota, a 429, an abort, a cut-off - and the turn would end
5540
+ # with its work unresumed. Filled with the slice id, its failure class and the id
5541
+ # twice more.
5542
+ STOPPED_SLICE = (
5543
+ "{SLICE_LEAD}Slice %s, spawned by this session, stopped (%s) and "
5544
+ "its work is not resumed. Resume it with `tezgah-task slice resume %s` - "
5545
+ "the native path first, else the re-dispatch brief it prints - or, if the "
5546
+ "work was finished another way, record that with `tezgah-task slice done "
5547
+ "%s`.")
5548
+
5549
+ # The `ready steps` class (plan 086, S1): the newest todo list (`note_todo`)
5550
+ # holds a pending item, not taken and not blocked, and the reply ends the turn
5551
+ # without it. Filled with the count and up to three heads.
5552
+ READY_STEPS = (
5553
+ "{READY_LEAD}The newest todo list holds %d ready step(s) this "
5554
+ "turn neither did nor typed as a blocker: %s. Dispatch every ready step "
5555
+ "now - independent ones as subagents in one message - and tick each one "
5556
+ "you finish. A step that waits on a running slice or on the user is marked "
5557
+ "blocked in the todo list (omp `block`, naming the slice or the blocker), "
5558
+ "or named in the reply on one line with its {DECISION}, {ACCESS}, {IRREVERSIBLE} "
5559
+ "or {ENV}. End the turn only when no step is ready.")
5560
+
5561
+ # The `open todo` class (plan 097): the reply claims done while the newest todo
5562
+ # list still holds an in-progress item, not blocked (`open_left`). Filled with
5563
+ # the count and up to three heads.
5564
+ OPEN_TODO = (
5565
+ "The reply claims done, but the newest todo list still holds %d item(s) in "
5566
+ "progress: %s. Finish them and mark them done in the todo list, or say "
5567
+ "plainly which are left and why - each on one line with its {DECISION}, "
5568
+ "{ACCESS}, {IRREVERSIBLE} or {ENV}, or marked blocked in the todo list "
5569
+ "with its reason.")
5570
+
5571
+
5572
+ def _tr_lower(text):
5573
+ """`text` lowercased the Turkish way (I -> ı, İ -> i): `str.lower` reads
5574
+ "DOĞRULANMADI" as "doğrulanmadi" and "İleride" with a combining dot."""
5575
+ return text.replace("I", "ı").replace("İ", "i").lower()
5576
+
5577
+
5578
+ def _label(line):
5579
+ """(label, rest) when `line` opens with a label - a markdown heading, a bold
5580
+ or italic lead (cut at its own colon), or at most six words before a colon -
5581
+ after an optional list marker, quote marker or leading symbol (an emoji),
5582
+ else None. `rest` is what follows the label on its line."""
5583
+ s = QUOTE_MARK.sub("", line).strip()
5584
+ marker = LIST_MARK.match(s)
5585
+ if marker:
5586
+ s = s[marker.end():]
5587
+ s = LEAD_SYMBOLS.sub("", s)
5588
+ m = re.match(r"#{1,6}\s+(.+)$", s)
5589
+ if m:
5590
+ return LEAD_SYMBOLS.sub("", m.group(1)).strip(" *_:"), ""
5591
+ m = re.match(r"(\*\*|__|\*|_)(.+?)\1(.*)$", s)
5592
+ if m:
5593
+ head, colon, inner = m.group(2).partition(":")
5594
+ rest = (inner + " " + m.group(3).strip().lstrip(":")) if colon \
5595
+ else m.group(3).strip().lstrip(":")
5596
+ return head.strip(), rest.strip()
5597
+ head, colon, rest = s.partition(":")
5598
+ if colon and head.strip() and len(head.split()) <= 6 and "`" not in head:
5599
+ return head.strip(), rest.strip()
5600
+ return None
5601
+
5602
+
5603
+ def _residual(label):
5604
+ """True when `label` names work that is left: it opens, after at most two
5605
+ words, with a RESIDUAL_LABEL form (TODO only in capitals, the marker), its
5606
+ own words close nothing ("Eksikler giderildi" is done, "Kısmen güncellendi"
5607
+ is not) and it does not say nothing is left - in a few words, or after its
5608
+ last colon ("Engeller: yok")."""
5609
+ if label.startswith("TODO"):
5610
+ return True
5611
+ for low in (_tr_lower(label), label.lower()):
5612
+ core = " ".join(PARENTHESIS.sub(" ", low).split())
5613
+ _head, colon, after = core.rpartition(":")
5614
+ if not RESIDUAL_AT.match(core) or (colon and EMPTY_ITEM.fullmatch(after)) or (
5615
+ NOTHING_LEFT.search(core) and len(core.split()) <= NOTHING_SHORT
5616
+ and not re.search(r"[,;:—–]", core)):
5617
+ continue
5618
+ if PARTIAL.match(core) or not (any(asserted_claims(core))
5619
+ or LABEL_DONE.search(core)):
5620
+ return True
5621
+ return False
5622
+
5623
+
5624
+ def _bare_label(line):
5625
+ """The text of a plain line that is a label with no colon, else "": at most
5626
+ four words that open on a RESIDUAL_FIRST or RESIDUAL_LABEL form with only a
5627
+ suffix after it ("Açık kalanlar", "Unresolved"). "Pending changes" and
5628
+ "Kod değişikliği yapılmadı" name something else and are no label."""
5629
+ s = LEAD_SYMBOLS.sub("", line.strip()).strip()
5630
+ if not s or ":" in s or len(s.split()) > 4:
5631
+ return ""
5632
+ for low in (_tr_lower(s), s.lower()):
5633
+ core = " ".join(PARENTHESIS.sub(" ", low).split())
5634
+ m = re.match(RESIDUAL_FIRST, core) or RESIDUAL_LABEL.match(core)
5635
+ if m and not re.search(r"\s", core[m.end():]):
5636
+ return s
5637
+ return ""
5638
+
5639
+
5640
+ def _indent(line):
5641
+ return len(line) - len(line.lstrip())
5642
+
5643
+
5644
+ def _sections(text):
5645
+ """[(label, intro, items)] for every residual section, outside fences and
5646
+ tables, a quote marker read as plain text. A list item that opens with the
5647
+ label holds itself alone - its rest and its deeper lines. A markdown heading
5648
+ holds the lines up to the next heading of its level or above, or prose after
5649
+ a blank line once it holds something. Any other label line holds its rest
5650
+ and the lines under it, up to the next heading, the next label at its
5651
+ depth, or that prose. The list entries at the shallowest depth are the
5652
+ items, and what comes before the first is the intro; with no list,
5653
+ everything is one item. A fenced block belongs to the item it follows,
5654
+ list-shaped lines inside it included."""
5655
+ lines = [QUOTE_MARK.sub(r"\1", ln) for ln in str(text or "").split("\n")]
5656
+ out, i, inside = [], 0, False
5657
+ while i < len(lines):
5658
+ line = lines[i]
5659
+ i += 1
5660
+ if FENCE.match(line):
5661
+ inside = not inside
5662
+ continue
5663
+ found = None if inside or TABLE_ROW.match(line) else _label(line)
5664
+ if (not found and not inside and not TABLE_ROW.match(line)
5665
+ and not LIST_MARK.match(line) and i < len(lines)
5666
+ and LIST_MARK.match(lines[i]) and _bare_label(line)):
5667
+ # a label line with no colon right over a list ("Açık kalanlar\n-
5668
+ # ...", S4 finding 4)
5669
+ found = (_bare_label(line), "")
5670
+ if not found or not _residual(found[0]):
5671
+ continue
5672
+ label, rest = found
5673
+ depth, own = _indent(line), bool(LIST_MARK.match(line))
5674
+ level = HEADING.match(line) and len(line.strip()) - len(line.strip().lstrip("#"))
5675
+ body, blank, fenced = [], False, False
5676
+ while i < len(lines):
5677
+ nxt = lines[i]
5678
+ if FENCE.match(nxt):
5679
+ fenced = not fenced
5680
+ elif not fenced:
5681
+ if not nxt.strip():
5682
+ blank = True
5683
+ i += 1
5684
+ continue
5685
+ deeper = _indent(nxt) > depth
5686
+ if own and not deeper:
5687
+ break
5688
+ held = rest or any(b.strip() for b in body)
5689
+ prose_after = (not deeper and not LIST_MARK.match(nxt)
5690
+ and blank and held)
5691
+ head = HEADING.match(nxt) and len(nxt.strip()) - len(nxt.strip().lstrip("#"))
5692
+ if level:
5693
+ if (head and head <= level) or (not head and prose_after):
5694
+ break
5695
+ elif not deeper and (head or prose_after or (
5696
+ not LIST_MARK.match(nxt) and _label(nxt))):
5697
+ break
5698
+ body.append(nxt)
5699
+ i += 1
5700
+ out.append(_items(label, rest, body, own))
5701
+ return out
5702
+
5703
+
5704
+ def _items(label, rest, body, own):
5705
+ """(label, intro, items) for one section's body (`_sections`)."""
5706
+ marks, fenced = [], False
5707
+ for b in body:
5708
+ if FENCE.match(b):
5709
+ fenced = not fenced
5710
+ marks.append(not fenced and not FENCE.match(b) and bool(LIST_MARK.match(b)))
5711
+ top = min((_indent(b) for b, m in zip(body, marks) if m), default=None)
5712
+ pre, entries = [], []
5713
+ for b, m in zip(body, marks):
5714
+ if m and _indent(b) == top:
5715
+ entries.append(b[LIST_MARK.match(b).end():])
5716
+ elif entries:
5717
+ entries[-1] += "\n" + b
5718
+ else:
5719
+ pre.append(b)
5720
+ if entries and not own:
5721
+ return (label, " ".join([rest] + pre).strip(), entries)
5722
+ return (label, "", ["\n".join([rest] + body).strip()])
5723
+
5724
+
5725
+ def residual_sections(text):
5726
+ """[(label, items)] for every residual section of a reply (`_sections`):
5727
+ what the Stop rule's `residual work` class and the replay's per-turn count
5728
+ both read."""
5729
+ return [(label, items) for label, _intro, items in _sections(text)]
5730
+
5731
+
5732
+ def residual_text(text):
5733
+ """The residual sections of `text` written back out - each as a heading,
5734
+ which holds a label of any length and its code, then its intro and one `- `
5735
+ line per item - so a store that keeps only part of a reply (Cursor's head
5736
+ and tail) still hands the Stop rule what the cut would drop. Reads back
5737
+ through `_sections` as the same sections."""
5738
+ return "".join("## %s\n%s%s\n\n" % (label, intro + "\n" if intro else "", "\n".join(
5739
+ "- " + item.replace("\n", "\n ") for item in items))
5740
+ for label, intro, items in _sections(text))
5741
+
5742
+
5743
+ def _quotes_failure(item, failed):
5744
+ """True when a code span or a fenced line of `item` quotes a command that
5745
+ failed: the span is one of its commands, holds one whole (two words at
5746
+ least, so a bare `git` that failed is not `git push`), or opens one word for
5747
+ word (`pytest` for `pytest -q`). A span cut short with `…` or `...` opens
5748
+ one with what is left of it, two words at least (`tezgah-task note 001
5749
+ state …`). A fragment such as `src` of `ls src/e2e` quotes nothing."""
5750
+ spans = CODE_SPAN.findall(item) + [
5751
+ ln for body in FENCED_BODY.findall(item) for ln in body.split("\n")]
5752
+ for span in (re.sub(r"^\$\s+", "", s.strip()) for s in spans):
5753
+ head = re.sub(r"\s*(?:…|\.\.\.)$", "", span)
5754
+ shortened = head != span and len(head.split()) >= 2
5755
+ for cmd in failed:
5756
+ for seg in (s.strip() for s in COMMAND_SPLIT.split(cmd)):
5757
+ if not seg or not span:
5758
+ continue
5759
+ if seg == span or (seg in span and len(seg.split()) >= 2) or (
5760
+ seg.startswith(span) and seg[len(span)].isspace()) or (
5761
+ shortened and seg.startswith(head)):
5762
+ return True
5763
+ return False
5764
+
5765
+
5766
+ def _failed_commands(rows):
5767
+ """The commands that failed in `rows`: a `verify_fail`, or a shell row with
5768
+ exit 1. A write tool's `edit` row is none (its detail is a path), and
5769
+ neither is a search alone that matched nothing (NO_MATCH_PROGRAMS: grep's
5770
+ exit 1 is its "no"); a grep after `&&`, `;` or a pipe reports the line's
5771
+ exit, so that row stays a failure."""
5772
+ out = []
5773
+ for r in rows:
5774
+ if r.get("kind") == "edit" or not (r.get("exit") == 1
5775
+ or r.get("kind") == "verify_fail"):
5776
+ continue
5777
+ cmd = str(r.get("detail") or "").replace(FAILED_MARK, "").strip()
5778
+ segs = [words for words in _shell_segments(cmd) if words]
5779
+ if (r.get("kind") != "verify_fail" and len(segs) == 1
5780
+ and segs[0][0] in NO_MATCH_PROGRAMS):
5781
+ continue
5782
+ out.append(cmd)
5783
+ return out
5784
+
5785
+
5786
+ def _spawn_failed(rows):
5787
+ """True when `rows` hold a spawn call (SUBAGENT_TOOLS) the host answered
5788
+ with a provider failure (SPAWN_FAIL_CLASSES): the ledger's evidence for a
5789
+ YETKİ or ORTAM that no shell command shows."""
5790
+ return any(str(r.get("tool") or "").lower() in SUBAGENT_TOOLS
5791
+ and r.get("fail_class") in SPAWN_FAIL_CLASSES for r in rows)
5792
+
5793
+
5794
+ def _tried_holds(item, said, failed, spawn_failed=False):
5795
+ """True when a YETKİ or ORTAM item carries its evidence: the error its type
5796
+ names (ENV_ERROR for ORTAM/ENV, ACCESS_ERROR for YETKİ/ACCESS), read in the
5797
+ item outside the type token and the quoted command, beside a quote of a
5798
+ command that failed (`_quotes_failure`), or - for an item that names a
5799
+ subagent, an agent, a provider or a model (SPAWN_WORDS) - in a turn whose
5800
+ spawn call failed at the provider (`spawn_failed`). A failure of another
5801
+ kind - an assertion, a typo, a grep that matched nothing - is doable work,
5802
+ whatever it is typed. So is a quota or rate limit on a model provider
5803
+ while another family the routing allows is up (`_limit_move`)."""
5804
+ kinds = {m.lastgroup for m in BLOCKER_TRIED.finditer(said)}
5805
+ rest = re.sub(BLOCKER_TRIED.pattern + BLOCKER_ABSENT, " ", item)
5806
+ rest = CODE_SPAN.sub(lambda m: " " if _quotes_failure(m.group(0), failed)
5807
+ else m.group(0), rest)
5808
+ if not any((ENV_ERROR if k == "env" else ACCESS_ERROR).search(rest)
5809
+ for k in kinds):
5810
+ return False
5811
+ if _limit_move(item, failed, spawn_failed):
5812
+ return False
5813
+ return bool(spawn_failed and SPAWN_WORDS.search(_tr_lower(item))) or \
5814
+ _quotes_failure(item, failed)
5815
+
5816
+
5817
+ # The move a refused limit item can make (`_limit_move`), added to the
5818
+ # residual-work refusal so the reply knows what satisfies it.
5819
+ LIMIT_MOVE = (" A quota or rate limit on one model family is no {ACCESS} or {ENV} "
5820
+ "while another is up: the work can move to %s - re-dispatch the "
5821
+ "slice there.")
5822
+
5823
+
5824
+ def _limit_move(item, failed=(), spawn_failed=False):
5825
+ """The family the work of `item` can move to, else None (research
5826
+ orchestrator-flow-audit S2, F3b): the item quotes a quota or rate limit
5827
+ (`tezgah_task.classify`) of a model provider - it names a family
5828
+ (`tezgah_models.named_families`), or the turn's spawn failed at the provider
5829
+ and the item quotes no failed command - and a family the routing allows is
5830
+ up (`tezgah_models.other_family_up`), never one the item names. A GitHub
5831
+ or npm rate limit stays a blocker: words such as "agent" or "provider" tie
5832
+ nothing ("user-agent", "registry provider"). Fails open: None on any error."""
5833
+ try:
5834
+ import tezgah_task
5835
+ if tezgah_task.classify(item) not in ("quota", "rate_limit"):
5836
+ return None
5837
+ import tezgah_models
5838
+ if not (tezgah_models.named_families(item)
5839
+ or (spawn_failed and not _quotes_failure(item, failed))):
5840
+ return None
5841
+ return tezgah_models.other_family_up(item)
5842
+ except Exception: # noqa: BLE001 - a health read never refuses on its own failure
5843
+ return None
5844
+
5845
+
5846
+ # An item under a blocker label that tells a blocker it already removed: an
5847
+ # earlier sentence in the past ("reddediyordu", "yoktu") and a last sentence
5848
+ # that opens on "Artık" with a present verb ("Artık var", "Artık
5849
+ # reddedilmiyor", S4 finding 10). A negative past ("güncellenmedi") reports work
5850
+ # not done, and "şimdi"/"kendi" only end like a past tense, so neither is WAS.
5851
+ # A last sentence that still names work left (a RESIDUAL_LABEL stem) or a
5852
+ # future ("koşulacak") keeps the item open. ponytail: one adverb rule, not a
5853
+ # tense reader.
5854
+ BLOCKER_LABEL = re.compile(r"\W*(?:%s)" % alts("blocker_labels"))
5855
+ WAS = re.compile(r"\b(?!(?:%s)\b)(?:%s)\b" % (alts("was_exclude"), alts("was")))
5856
+ NOW = re.compile(alts("now"), re.S)
5857
+ FUTURE = re.compile(alts("future"))
5858
+
5859
+
5860
+ def _now_closed(item, label):
5861
+ """True when `item`, under a blocker `label`, reports a blocker that is
5862
+ gone (WAS, then NOW, with nothing left or due in the last sentence)."""
5863
+ if not BLOCKER_LABEL.match(_tr_lower(label)):
5864
+ return False
5865
+ sentences = re.split(r"(?<=[.!?])\s+", _tr_lower(_prose(item)).strip())
5866
+ last = sentences[-1].strip(" *_")
5867
+ return (len(sentences) > 1 and bool(NOW.search(last))
5868
+ and not RESIDUAL_LABEL.search(last) and not FUTURE.search(last)
5869
+ and any(WAS.search(s) for s in sentences[:-1]))
5870
+
5871
+
5872
+ # The ask policy (plan 086, S3; the CORE integrity paragraph's "Ask policy").
5873
+ # Its vocabulary is the packs' `ask` map: the process-question shapes
5874
+ # (PROCESS_ASK: "Başlayayım mı?", "shall I proceed?", "which first?"), the
5875
+ # never-default class (NEVER_DEFAULT, and SPEND over `spend_cap`), a
5876
+ # recommended default (DEFAULT_MARK), a decision's options (OPTIONS_MARK), a
5877
+ # bare approval (CHEAP_ANSWER) and the outward classes a grant covers
5878
+ # (OUTWARD). QUESTION_END is a last line asking the user (`?`, or a pack's
5879
+ # question particle). ponytail: word lists read on the last line, not a reader
5880
+ # of what the question means; `tezgah-gate replay` counts what it refuses
5881
+ # (`process_questions`) to measure its precision.
5882
+ QUESTION_END = re.compile(r"(?:\?|\b(?:%s)[.!]?)$" % alts("question_end"))
5883
+ PROCESS_ASK = re.compile(alts("ask", "process"), re.I)
5884
+ NEVER_DEFAULT = re.compile(alts("ask", "never_default"), re.I)
5885
+ SPEND = re.compile(alts("ask", "spend"), re.I)
5886
+ AMOUNT = re.compile(r"\$\s?(\d+(?:\.\d+)?)|(\d+(?:\.\d+)?)\s?(?:\$|usd\b|dolar)", re.I)
5887
+ DEFAULT_MARK = re.compile(alts("ask", "default_mark"), re.I)
5888
+ OPTIONS_MARK = re.compile(alts("ask", "options"), re.I)
5889
+ CHEAP_ANSWER = re.compile(r"\W*(?:%s)\W*" % alts("ask", "cheap_answer"), re.I)
5890
+ OUTWARD = {cls: re.compile("|".join(
5891
+ p for out in pack_values("ask", "outward") for c, p in out.items() if c == cls), re.I)
5892
+ for cls in dict.fromkeys(c for out in pack_values("ask", "outward") for c in out)}
5893
+ GRANT_KIND = "grant"
5894
+ GRANT_ROW = re.compile(r'"kind"\s*:\s*"%s"' % GRANT_KIND)
5895
+ ASK_TOOLS = ("ask", "askuserquestion")
5896
+ # Filled with the question the reply ends on. No grant shape in it ("without
5897
+ # asking"): omp hands a refusal back as a prompt, and the prompt hook reads
5898
+ # grants off every prompt (`tezgah_context.user_grants`).
5899
+ NEEDLESS_ASK = (
5900
+ "{ASK_LEAD}The reply ends on a question the agent can decide (\"%s\"). "
5901
+ "Work and process steps - the order, \"shall I go on\", a check, a local "
5902
+ "merge, a run parameter within the standing spend cap - are taken and "
5903
+ "stated in one line, and an outward step the user granted this session is "
5904
+ "taken, not asked again. An outward step the user has not granted waits "
5905
+ "for the answer and never proceeds on a default. Do it now, or, when only "
5906
+ "the user can decide it, put it under a \"{APPROVAL_HEADING}\" heading with "
5907
+ "a \"{RECOMMENDED}:\" default and proceed on that default.")
5908
+
5909
+
5910
+ def outward_classes(text):
5911
+ """The OUTWARD classes `text` names (push, release, publish, pr)."""
5912
+ low = _tr_lower(str(text or ""))
5913
+ return {cls for cls, rx in OUTWARD.items() if rx.search(low)}
5914
+
5915
+
5916
+ def never_default(text):
5917
+ """True when `text` names a class-F step - one no default may take: a
5918
+ force push, a delete of what the session did not make, production, a
5919
+ credential or an account, a send to a third party (NEVER_DEFAULT), or a
5920
+ spend (SPEND) with no amount or one over `spend_cap`."""
5921
+ low = _tr_lower(str(text or ""))
5922
+ if NEVER_DEFAULT.search(low):
5923
+ return True
5924
+ if not SPEND.search(low):
5925
+ return False
5926
+ amounts = [float(a or b) for a, b in AMOUNT.findall(low)]
5927
+ return not amounts or max(amounts) > spend_cap()
5928
+
5929
+
5930
+ def grants_in_force(session_id):
5931
+ """The OUTWARD classes the user granted this session and has not lifted,
5932
+ folded oldest first over the session's `grant` rows (`target`: a class,
5933
+ `all`, or `lift:<class|all>`). Only the lines in the row's serialized shape
5934
+ are parsed, as `ready_left` reads `todo` rows."""
5935
+ granted = set()
5936
+ lines = [line for line in _ledger_lines(_path(session_id)) if GRANT_ROW.search(line)] \
5937
+ if session_id else []
5938
+ for row in _parse(lines):
5939
+ target = str(row.get("target") or "")
5940
+ if target == "all":
5941
+ granted = set(OUTWARD)
5942
+ elif target == "lift:all":
5943
+ granted = set()
5944
+ elif target.startswith("lift:"):
5945
+ granted.discard(target[len("lift:"):])
5946
+ elif target in OUTWARD:
5947
+ granted.add(target)
5948
+ return granted
5949
+
5950
+
5951
+ def needless_ask(text, granted=frozenset):
5952
+ """The question the reply ends on when the agent can decide it, else None.
5953
+
5954
+ The last line asks (QUESTION_END), its paragraph carries no recommended
5955
+ default (DEFAULT_MARK) and it names no class-F step (`never_default`); it
5956
+ is then needless when it names an outward step (OUTWARD) the user granted
5957
+ this session, every one it names (`granted()`, called only then, so a reply
5958
+ that ends on no question reads no ledger), or, naming none, when it asks a
5959
+ process question (PROCESS_ASK). A question about an ungranted outward step
5960
+ is the one the policy asks: "push edip PR açayım mı?" waits."""
5961
+ t = str(text or "").strip()
5962
+ lines = [ln.strip().rstrip("*_` ") for ln in t.splitlines() if ln.strip()]
5963
+ if not lines or not QUESTION_END.search(lines[-1]):
5964
+ return None
5965
+ last = lines[-1]
5966
+ if never_default(last) or DEFAULT_MARK.search(_tr_lower(re.split(r"\n\s*\n", t)[-1])):
5967
+ return None
5968
+ low = _tr_lower(last)
5969
+ outward = outward_classes(low)
5970
+ if outward:
5971
+ return last if outward <= set(granted()) else None
5972
+ if PROCESS_ASK.search(low):
5973
+ return last
5974
+ return None
5975
+
5976
+
5977
+ def undefaulted(inp):
5978
+ """(questions, with a default) of an ask-tool payload: omp's `ask` marks
5979
+ the default with a question's `recommended` index, Claude's
5980
+ AskUserQuestion with an option label or description that names it
5981
+ (DEFAULT_MARK: "(Recommended)", "(önerilen)")."""
5982
+ inp = inp if isinstance(inp, dict) else {}
5983
+ qs = inp.get("questions") if isinstance(inp.get("questions"), list) else [inp]
5984
+ qs = [q for q in qs if isinstance(q, dict) and q.get("question")]
5985
+
5986
+ def has_default(q):
5987
+ if isinstance(q.get("recommended"), int) and not isinstance(q.get("recommended"), bool):
5988
+ return True
5989
+ return any(isinstance(o, dict) and DEFAULT_MARK.search(_tr_lower(
5990
+ "%s %s" % (o.get("label") or "", o.get("description") or "")))
5991
+ for o in q.get("options") or ())
5992
+ return len(qs), sum(1 for q in qs if has_default(q))
5993
+
5994
+
5995
+ # A blocker heading with nothing under it lists no blocker ("## Engeller" last).
5996
+ BLOCKERS_ONLY = re.compile(r"\s*(?:%s)\s*" % alts("blockers_only"))
5997
+
5998
+
5999
+ def unblocked_items(text, rows):
6000
+ """[(label, item)] for every residual item nothing accounts for. A GERİ
6001
+ ALINAMAZ token in the label, the intro or the item accounts for it, and so
6002
+ does a KARAR token beside the decision's options or a recommended default
6003
+ (`BLOCKER_SAID`, OPTIONS_MARK, DEFAULT_MARK, on the prose): a choice with
6004
+ neither is one the agent can make (the ask policy). A YETKİ or ORTAM token
6005
+ does only with its
6006
+ evidence (`_tried_holds`: the error of its type and its ledger evidence) -
6007
+ the type and its evidence, never one of the two. An empty blocker list
6008
+ (BLOCKERS_ONLY), an item that says nothing is left and one that reports a
6009
+ blocker already gone (`_now_closed`) are none; an empty item under any other
6010
+ label is the label itself ("**V4 ölçülmedi.**")."""
6011
+ failed = _failed_commands(rows)
6012
+ spawn_failed = _spawn_failed(rows)
6013
+ out = []
6014
+ for label, intro, items in _sections(text):
6015
+ for item in items:
6016
+ if (EMPTY_ITEM.fullmatch(item.strip()) or _now_closed(item, label) or (
6017
+ not item.strip() and BLOCKERS_ONLY.fullmatch(_tr_lower(label)))):
6018
+ continue
6019
+ said = _prose(" ".join((label, intro, item)))
6020
+ if BLOCKER_SAID.search(said) and (
6021
+ IRREVERSIBLE_SAID.search(said) or OPTIONS_MARK.search(_tr_lower(said))
6022
+ or DEFAULT_MARK.search(_tr_lower(said))):
6023
+ continue
6024
+ if BLOCKER_TRIED.search(said) and _tried_holds(item, said, failed,
6025
+ spawn_failed):
6026
+ continue
6027
+ out.append((label, item))
6028
+ return out
6029
+
6030
+
6031
+ def _residual_block(text, rows, shape, verdict=(None, None), session_id=None,
6032
+ cwd=None):
6033
+ """`verdict` when it refuses; else, in a turn that did work (a STEP_KINDS
6034
+ row), the `residual work` class when `unblocked_items` names one, or when a
6035
+ subagent ended in the turn handing up items no type accounts for (the
6036
+ `residual` count on its `subagent_end` row) and this reply types none; else
6037
+ the `needless ask` class when `needless_ask` names the question the reply
6038
+ ends on (any turn, worked or not: the plan proposal that ends "Başlayayım
6039
+ mı?" is its measured case); else the `ready steps` class when `ready_left`
6040
+ names one; else the `open todo` class when `open_left` names one; else the
6041
+ `stopped slice` class when `_stopped_slice` finds one; else `verdict`. Not
6042
+ asked for a subagent's end (`shape` false) or a nested session: both
6043
+ replies are read by code, not by the user (`_shape_block`). Residual work
6044
+ is not asked of a session whose ledger holds no turn marker either:
6045
+ `turn_rows` hands it the whole ledger as the turn, so the steps it sees are
6046
+ not this turn's. ponytail: one typed item in the reply covers every item
6047
+ handed up - the rule cannot tell which child item a parent item answers."""
6048
+ if verdict[0] or not shape or os.environ.get("TEZGAH_NESTED"):
6049
+ return verdict
6050
+ left = []
6051
+ # the newest end of each child counts once (`agent`): a child that ended
6052
+ # twice, or a re-sent end, is one hand-up
6053
+ handed = sum({r.get("agent") or id(r): int(r.get("residual") or 0)
6054
+ for r in rows if r.get("kind") == "subagent_end"}.values())
6055
+ if handed or any(str(r.get("kind")) in STEP_KINDS for r in rows):
6056
+ _settle_slices(cwd)
6057
+ left = unblocked_items(text, rows)
6058
+ if handed and not left and not any(
6059
+ item.strip() and not EMPTY_ITEM.fullmatch(item.strip())
6060
+ for _label, _intro, entries in _sections(text) for item in entries):
6061
+ left = [("subagent report", "%d untyped item(s) handed up" % handed)]
6062
+ if left and session_id and not _turn_count(_ledger_lines(_path(session_id))):
6063
+ left = []
6064
+ if left:
6065
+ named = "; ".join("%s: %s" % (label, cut(" ".join(item.split()), 48))
6066
+ for label, item in left[:3])
6067
+ failed, spawn_failed = _failed_commands(rows), _spawn_failed(rows)
6068
+ move = next(filter(None, (_limit_move(item, failed, spawn_failed)
6069
+ for _label, item in left)), None)
6070
+ return ("residual work", RESIDUAL_WORK % named + (LIMIT_MOVE % move if move else ""))
6071
+ asked = needless_ask(text, lambda: grants_in_force(session_id))
6072
+ if asked:
6073
+ return ("needless ask", NEEDLESS_ASK % cut(asked, 80))
6074
+ todo = _turn_todo(rows, session_id)
6075
+ ready = _ready_in(text, todo, session_id, cwd)
6076
+ if ready:
6077
+ return ("ready steps", READY_STEPS % (
6078
+ len(ready), "; ".join(cut(head, 48) for head in ready[:3])))
6079
+ taken = open_left(text, todo, session_id, cwd)
6080
+ if taken:
6081
+ return ("open todo", OPEN_TODO % (
6082
+ len(taken), "; ".join(cut(head, 48) for head in taken[:3])))
6083
+ stopped = _stopped_slice(session_id, cwd)
6084
+ if not stopped:
6085
+ return verdict
6086
+ sid = str(stopped.get("id") or "")
6087
+ return ("stopped slice", STOPPED_SLICE % (
6088
+ sid, stopped.get("failure") or "error", sid, sid))
6089
+
6090
+
6091
+ def ready_heads(row, session_id, cwd):
6092
+ """A `todo` row's ready heads (`note_todo`), less each one an open slice of
6093
+ this session is working on (`_not_running`). omp's `todo start` sets every
6094
+ other `in_progress` item back to `pending`, so a dispatched item reads as
6095
+ ready in the list while its slice runs."""
6096
+ return _not_running([str(h) for h in row.get("heads") or () if str(h).strip()],
6097
+ session_id, cwd)
6098
+
6099
+
6100
+ def _not_running(heads, session_id, cwd):
6101
+ """`heads` less each one an open slice of this session is working on: the
6102
+ slice id appears in the head, or the whole head in the slice's own task
6103
+ (`task`, without an omp batch's shared context), each as a word-bounded
6104
+ match of two characters or more, case-insensitive. ponytail: a record
6105
+ written before `task` existed falls back to its brief's last paragraph.
6106
+ Read from `tezgah_task.pending`, off under `slices-off`, and fail open to
6107
+ `heads`."""
6108
+ if not (heads and session_id and cwd) or off("slices-off"):
6109
+ return heads
6110
+ try:
6111
+ pending = getattr(_journal(), "pending", None)
6112
+ root = _toplevel(os.path.realpath(cwd))
6113
+ running = [(str(r.get("id") or "").lower(), " ".join(str(
6114
+ r.get("task") or str(r.get("brief") or "").rsplit("\n\n", 1)[-1]
6115
+ ).split()).lower())
6116
+ for r in (pending(root) if pending and root else [])
6117
+ if r.get("status") == "open" and r.get("parent") == session_id]
6118
+ except Exception:
6119
+ return heads
6120
+
6121
+ def word(needle, hay):
6122
+ return len(needle) > 1 and re.search(
6123
+ r"(?<![\w-])%s(?![\w-])" % re.escape(needle), hay)
6124
+ return [h for h in heads
6125
+ if not any(word(sid, h.lower()) or word(h.lower(), task)
6126
+ for sid, task in running)]
6127
+
6128
+
6129
+ def newest_todo(rows):
6130
+ """The first `todo` row of the session's own list in `rows`, which run
6131
+ newest first, else None: a subagent's row (`agent`) is its own queue,
6132
+ never the parent's. Lazy, so a generator stops at the row it needs."""
6133
+ return next((r for r in rows if r.get("kind") == "todo" and not r.get("agent")), None)
6134
+
6135
+
6136
+ def _turn_todo(rows, session_id):
6137
+ """The newest parent `todo` row the Stop path judges, else a falsy value
6138
+ (`[]` on the gated exits, the neuter matrix's anchors). Asked like
6139
+ `residual work`: in a turn that did work, of a ledger with a turn marker,
6140
+ off under `orchestrate-off`. The row may come from an earlier turn: an
6141
+ unfinished step carries over. It is found by its serialized shape and only
6142
+ the lines back to the newest parent `todo` row are parsed, so the Stop path
6143
+ still parses the turn and not the session (StopReadBound). Read once for
6144
+ both todo classes."""
6145
+ if not session_id or off("orchestrate-off"):
6146
+ return []
6147
+ if not any(str(r.get("kind")) in STEP_KINDS for r in rows):
6148
+ return []
6149
+ lines = _ledger_lines(_path(session_id))
6150
+ if not _turn_count(lines):
6151
+ return []
6152
+ return newest_todo(r for line in reversed(lines) if TODO_ROW.search(line)
6153
+ for r in _parse([line]))
6154
+
6155
+
6156
+ def _untyped(text, heads):
6157
+ """`heads` less each one a line of the reply names (its first 24
6158
+ characters, inline code kept) beside a KARAR, YETKİ, GERİ ALINAMAZ or ORTAM
6159
+ token outside code."""
6160
+ typed = [_tr_lower(" ".join(line.split())) for line in str(text or "").splitlines()
6161
+ if BLOCKER_SAID.search(_prose(line)) or BLOCKER_TRIED.search(_prose(line))]
6162
+ return [h for h in heads if not any(_tr_lower(h)[:24] in line for line in typed)]
6163
+
6164
+
6165
+ def _ready_in(text, row, session_id, cwd=None):
6166
+ """`ready_left` over a row already read (`_turn_todo`)."""
6167
+ if not row or not row.get("ready"):
6168
+ return []
6169
+ return _untyped(text, ready_heads(row, session_id, cwd))
6170
+
6171
+
6172
+ def ready_left(text, rows, session_id, cwd=None):
6173
+ """The heads of the ready steps the reply leaves: the newest `todo` row's
6174
+ (`_turn_todo`) ready items (`ready_heads`), less each one the reply types
6175
+ with its blocker (`_untyped`). A done item is gone from the list, so it
6176
+ never needs the reply."""
6177
+ return _ready_in(text, _turn_todo(rows, session_id), session_id, cwd)
6178
+
6179
+
6180
+ def open_left(text, row, session_id=None, cwd=None):
6181
+ """The heads of the in-progress items a done claim leaves (the `open todo`
6182
+ class): the row's `open - blocked - ready` count, named by its `taken`
6183
+ heads, when the reply claims done (`asserted_claims`). A blocked item is
6184
+ not counted, an in-progress one a running slice of this session works on
6185
+ (`_not_running`: Claude's TodoWrite has no blocked status, so a dispatched
6186
+ step reads in_progress) is not either, and one the reply types with its
6187
+ blocker (`_untyped`) passes. A `partial` row (Cursor `merge: true`) holds
6188
+ only the changed items, so it is not judged. ponytail: a row written before
6189
+ `taken` existed is named by its count alone."""
6190
+ if not row or row.get("partial"):
6191
+ return []
6192
+ left = (int(row.get("open") or 0) - int(row.get("blocked") or 0)
6193
+ - int(row.get("ready") or 0))
6194
+ if left <= 0 or not asserted_claims(text)[0]:
6195
+ return []
6196
+ heads = [str(h) for h in row.get("taken") or () if str(h).strip()]
6197
+ if not heads:
6198
+ return ["%d in-progress item(s)" % left]
6199
+ return _untyped(text, _not_running(heads, session_id, cwd))
6200
+
6201
+
6202
+ def _settle_slices(cwd):
6203
+ """Close the journal's ended omp records (`tezgah_task.pending`) before the
6204
+ residual rule picks a move, so a child's limit stop already holds its
6205
+ family (`close_slice`) and the move never names it. Off under
6206
+ `slices-off`; fails open."""
6207
+ if not cwd or off("slices-off"):
6208
+ return
6209
+ try:
6210
+ pending = getattr(_journal(), "pending", None)
6211
+ root = _toplevel(os.path.realpath(cwd))
6212
+ if pending and root:
6213
+ pending(root)
6214
+ except Exception: # noqa: BLE001 - a broken journal costs the hold, never the Stop
6215
+ pass
6216
+
6217
+
6218
+ def _stopped_slice(session_id, cwd):
6219
+ """The newest slice record this session spawned that the journal holds as
6220
+ stopped (`tezgah_task.pending`), else None. A turn that did no work is
6221
+ asked too: the turn after an omp `/clear` is exactly the one that has
6222
+ forgotten the slice. Its own record only (`parent`), so a sibling
6223
+ session's stopped slice never refuses this one. Off under `slices-off`,
6224
+ and fail open: a broken journal costs the class, never the Stop. Refused
6225
+ once per turn like every class (`_record_only`)."""
6226
+ if not (session_id and cwd) or off("slices-off"):
6227
+ return None
6228
+ try:
6229
+ pending = getattr(_journal(), "pending", None)
6230
+ root = _toplevel(os.path.realpath(cwd))
6231
+ for rec in (pending(root) if pending and root else []):
6232
+ if rec.get("status") == "stopped" and rec.get("parent") == session_id:
6233
+ return rec
6234
+ except Exception:
6235
+ return None
6236
+ return None
6237
+
6238
+
4889
6239
  # The workspaces a probe or a benchmark runs in rather than a user: the temp
4890
6240
  # trees (/tmp, macOS's /var/folders), an OpenResearch run copy (`local-runs/`)
4891
6241
  # and the arm-bench instrument's run tree. tezgah's own cache is the fourth,
@@ -4961,8 +6311,8 @@ ORPHANED = (
4961
6311
  "Evidence tampered: a passing check in this turn has no `began` row from the "
4962
6312
  "gate before it (counted as an orphan), so it was written outside the hooks "
4963
6313
  "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.")
6314
+ "tested claim this turn. Run the check again through the tool, or drop the "
6315
+ "claim and say what is unverified; the ledger is the user's to inspect.")
4966
6316
  BEGAN_ROW = re.compile(r'"kind"\s*:\s*"%s"' % BEGAN_KIND)
4967
6317
  UNLOCKED_ROW = re.compile(r'"%s"\s*:\s*1\b' % UNLOCKED)
4968
6318
  # How many rows before a `turn_rows` slice seed `_pair`'s waiting `began` rows: