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