@r1z4x/tezgah 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +175 -1
  2. package/MANIFEST +18 -0
  3. package/NOTICE +152 -0
  4. package/README.de.md +41 -20
  5. package/README.es.md +39 -19
  6. package/README.fr.md +41 -20
  7. package/README.md +15 -8
  8. package/README.tr.md +39 -21
  9. package/bin/codegen +9 -2
  10. package/bin/consult +6 -0
  11. package/bin/tezgah-adhd +3 -0
  12. package/bin/tezgah-agents +3 -0
  13. package/bin/tezgah-capture +3 -0
  14. package/bin/tezgah-design +329 -54
  15. package/bin/tezgah-docs +157 -41
  16. package/bin/tezgah-dsh +1 -1
  17. package/bin/tezgah-gate +7 -2
  18. package/bin/tezgah-import-ai-research +16 -4
  19. package/bin/tezgah-index +3 -0
  20. package/bin/tezgah-lessons +13 -4
  21. package/bin/tezgah-mcp +4 -0
  22. package/bin/tezgah-migrate +3 -0
  23. package/bin/tezgah-ocr-review +2 -1
  24. package/bin/tezgah-optimize +4 -1
  25. package/bin/tezgah-pony +3 -0
  26. package/bin/tezgah-research +12 -6
  27. package/bin/tezgah-rollback +3 -0
  28. package/bin/tezgah-route +1 -1
  29. package/bin/tezgah-setup +596 -157
  30. package/bin/tezgah-status +11 -1
  31. package/bin/tezgah-task +3 -0
  32. package/bin/tezgah.js +10 -3
  33. package/commands/adhd.md +1 -1
  34. package/docs/README.md +19 -8
  35. package/docs/architecture.md +2 -2
  36. package/docs/citations-baseline.json +11 -17
  37. package/docs/clarity-baseline.json +3 -3
  38. package/docs/contract.md +77 -31
  39. package/docs/environment.md +58 -24
  40. package/docs/evidence.md +49 -19
  41. package/docs/gate.md +71 -21
  42. package/docs/hosts.md +65 -52
  43. package/docs/layers.md +1 -1
  44. package/docs/operations.md +67 -24
  45. package/docs/research.md +1 -1
  46. package/docs/skills.md +31 -8
  47. package/docs/status-line.md +33 -23
  48. package/docs/testing.md +48 -17
  49. package/docs/vision.md +6 -2
  50. package/hooks/lang/en.json +5 -2
  51. package/hooks/lang/tr.json +3 -2
  52. package/hooks/projects-posttooluse.py +11 -3
  53. package/hooks/projects-stop.py +1 -1
  54. package/hooks/tezgah_context.py +40 -23
  55. package/hooks/tezgah_gate.py +404 -106
  56. package/hooks/tezgah_integrity.py +367 -97
  57. package/hooks/tezgah_lessons.py +5 -2
  58. package/hooks/tezgah_paths.py +17 -6
  59. package/hooks/tezgah_policy.py +13 -5
  60. package/hooks/tezgah_skill_pick.py +1 -1
  61. package/hooks/tezgah_snapshot.py +6 -3
  62. package/hooks/tezgah_stopspec.py +2 -1
  63. package/hosts/amp/hook.py +1 -1
  64. package/hosts/amp/tezgah-plugin.ts.in +13 -3
  65. package/hosts/antigravity/hook.py +1 -1
  66. package/hosts/codex/hook.py +1 -1
  67. package/hosts/commandcode/hook.py +1 -1
  68. package/hosts/copilot/hook.py +1 -1
  69. package/hosts/cursor/hook.py +2 -1
  70. package/hosts/droid/hook.py +1 -1
  71. package/hosts/gemini/hook.py +1 -1
  72. package/hosts/goose/hook.py +1 -1
  73. package/hosts/omp/hook.py +1 -1
  74. package/hosts/omp/tezgah-hook.ts.in +127 -35
  75. package/hosts/opencode/plugins/tezgah.js +209 -63
  76. package/hosts/qwen/hook.py +1 -1
  77. package/output-styles/tezgah.md +1 -1
  78. package/package.json +6 -3
  79. package/packaging/build.sh +4 -4
  80. package/packaging/install.sh +11 -0
  81. package/packaging/upgrade.sh +3 -0
  82. package/skills/ai-research/LICENSE +21 -0
  83. package/skills/design-contract/SKILL.md +27 -6
  84. package/skills/design-library/LICENSE +21 -0
  85. package/skills/plan-add/render_table.py +7 -0
  86. package/skills/pm-frameworks/LICENSE +21 -0
  87. package/skills/rl-env/LICENSE +201 -0
  88. package/skills/tezgah-contract/SKILL.md +2 -2
  89. package/third_party/open-code-review/LICENSE +201 -0
  90. package/third_party/open-code-review/README.md +15 -0
  91. package/third_party/open-code-review/prompts/main_task_system.md +26 -0
  92. package/third_party/open-code-review/prompts/review_filter_task_system.md +13 -0
  93. package/workflows/graph-map.js +14 -5
@@ -42,14 +42,65 @@ from tezgah_paths import (SWITCHES, _toplevel, alts, cache_dir, language, latch,
42
42
  off, pack_values, packs, root_for, say, spend_cap)
43
43
  import tezgah_store as ts
44
44
 
45
+ # The wrappers every shell reader steps over to reach a segment's program, by
46
+ # basename, with the options (short and long) that take the next word as their
47
+ # value; `timeout` also takes its duration and `env -S` splits its string into
48
+ # words (`_past_wrappers`). A wrapper with arguments (`nice -n 5`, `timeout 60`,
49
+ # `sudo --user root`) hid `git commit -n` from the shortcut rule (audit B-001),
50
+ # a write from the control rule and a check from VERIFY (B-121). One table:
51
+ # hooks/tezgah_gate.py imports it with `_past_wrappers`.
52
+ # ponytail: a wrapper outside it (`setsid`, `chrt`, `taskset`, `doas`, `xargs`,
53
+ # `flock`) still hides the program behind it.
54
+ WRAPPER_VALUE_OPTS = {
55
+ "sudo": ("-u", "--user", "-g", "--group", "-h", "--host", "-p", "--prompt",
56
+ "-C", "--close-from", "-D", "--chdir", "-r", "--role", "-t", "--type",
57
+ "-U", "--other-user", "-T", "--command-timeout"),
58
+ # -S splits its string into words (`_past_wrappers`); -P, -L and -U are
59
+ # BSD env's (`/usr/bin/env -P /usr/bin git ...` on macOS)
60
+ "env": ("-u", "--unset", "-C", "--chdir", "-S", "-P", "-L", "-U"),
61
+ "nice": ("-n", "--adjustment"), "nohup": (), "exec": ("-a",), "command": (),
62
+ "builtin": (), "time": ("-f", "--format", "-o", "--output"),
63
+ "timeout": ("-s", "--signal", "-k", "--kill-after"),
64
+ "stdbuf": ("-i", "--input", "-o", "--output", "-e", "--error"),
65
+ "ionice": ("-c", "--class", "-n", "--classdata", "-p", "--pid"),
66
+ "caffeinate": ("-t", "-w")}
67
+ # Words that open a compound command: the command after one is read as if it
68
+ # were not there (`if touch x; then`, `! rm x`, `do touch x`, `{ git commit
69
+ # -n; }`). `[`/`]` are not here: hooks/tezgah_gate.py::_program skips the test
70
+ # command, so the command after it in an `if` line is read.
71
+ SHELL_KEYWORDS = frozenset(("if", "then", "elif", "else", "do", "while",
72
+ "until", "!", "{", "time"))
73
+
74
+
75
+ def _wrapper_prefix():
76
+ """VERIFY's prefix: any run of WRAPPER_VALUE_OPTS wrappers, each with its
77
+ env assignments, options, option values (a short one may close a cluster,
78
+ `-Hu deploy`) and `timeout`'s duration. No two readings of a word both go
79
+ on, so a failed match stays linear (review F3): a value never starts with
80
+ `-` nor looks like an assignment (`-u a=b` reads only as an option and an
81
+ assignment, which ends the same), and one short alternative per wrapper
82
+ holds all its value letters, read case sensitively inside VERIFY's `re.I`
83
+ (`-u`/`-U` as two alternatives under `re.I` gave every `-u a` two readings)."""
84
+ alts = []
85
+ value = r"(?!\w+=)[^-\s]\S*\s+"
86
+ for name, opts in WRAPPER_VALUE_OPTS.items():
87
+ shorts = "".join(sorted({o[1] for o in opts if len(o) == 2 and o != "-S"}))
88
+ valued = r"-[A-Za-z]*[%s]\s+%s|" % (re.escape(shorts), value) if shorts else ""
89
+ valued += "".join(r"%s\s+%s|" % (re.escape(o), value)
90
+ for o in opts if o.startswith("--"))
91
+ alts.append(r"%s\s+(?:\w+=\S+\s+|%s-\S+\s+)*%s" % (
92
+ re.escape(name), valued, r"(?:\d\S*\s+)?" if name == "timeout" else ""))
93
+ return r"(?-i:(?:(?:[\w./-]*/)?(?:%s))*)" % "|".join(alts)
94
+
95
+
45
96
  # A command that actually checks the change, as opposed to one that merely runs.
46
97
  # Command position, like UI_CHECK: `echo pytest` and `cat pytest.ini` were
47
98
  # recorded as passing checks and let a "done, all tests pass" reply through
48
- # (audit M-1). The wrapper set keeps `sudo pytest` and `env X=1 pytest` counting;
49
- # without it they would be refused rather than missed.
99
+ # (audit M-1). The wrapper prefix keeps `sudo pytest`, `env X=1 pytest` and
100
+ # `timeout 60 pytest` counting; without it they would be refused rather than
101
+ # missed, and a neutered or piped one passed the gate (B-121).
50
102
  VERIFY = re.compile(
51
- r"(?:^|[|;&(])\s*"
52
- r"(?:(?:sudo|env|time|nohup|command|exec)\s+(?:\w+=\S+\s+)*(?:-\S+\s+)*)?"
103
+ r"(?:^|[|;&(])\s*" + _wrapper_prefix() +
53
104
  r"(?:" r"(?:python3?|uv run)\s+(?:(?:--project|--with|--python|--extra|--group|-p|-w)\s+\S+\s+|-\S+\s+)*(?:python3?\s+-m\s+)?(?:pytest|unittest|mypy|ruff|flake8|compileall|"
54
105
  r"(?:[\w./-]*/)?tezgah-design\s+(?:check|derive|slop)|"
55
106
  r"(?:[\w./-]*/)?tests[/\\]impacted\.py)|"
@@ -66,7 +117,7 @@ VERIFY = re.compile(
66
117
  r"go\s+(?:test|vet)|"
67
118
  r"cargo\s+(?:test|clippy|check|build)|"
68
119
  r"tox|nox|pre-commit|"
69
- r"(?:^|\s)(?:make|just)\b|"
120
+ r"(?:^|\s|(?<=\s))(?:make|just)\b|"
70
121
  r"\./\S*(?:test|check|lint)\S*|"
71
122
  r"(?:\./)?(?:gradlew|mvn)\s+\S*(?:test|check)|dotnet\s+(?:test|build)|"
72
123
  r"swift\s+test|golangci-lint|shellcheck|"
@@ -184,14 +235,25 @@ DESIGN_CHECK = re.compile(
184
235
  r"(?:check|slop)\b",
185
236
  re.I | re.M)
186
237
  # A component, as opposed to the screen it sits on: a file under a
187
- # component/views/widgets directory, or a name that carries the convention on its
188
- # own (`Button.tsx`, `users.component.ts`, `user_card.dart`). The two owe
189
- # different proof - a screen read says what a thing looks like, and the contract
190
- # is the only thing that says whether it is on the repository's floor - so this
191
- # is what decides whether the design check is owed at all.
238
+ # component/views/widgets/templates directory, a name that carries the
239
+ # convention on its own (`Button.tsx`, `users.component.ts`, `user_card.dart`),
240
+ # a single-file component (`.vue`, `.svelte`, `.astro` - in those frameworks the
241
+ # file IS the component, whatever its name's case), or the route file the
242
+ # frameworks with no `components/` convention render from (`app/<x>/page.tsx`,
243
+ # `+page.svelte`, `layout.tsx`). The two owe different proof - a screen read says
244
+ # what a thing looks like, and the contract is the only thing that says whether
245
+ # it is on the repository's floor - so this is what decides whether the design
246
+ # check is owed at all; a route file owes both, the screen read first, because
247
+ # nothing else in such a repository ever carries the floor. Its two readers
248
+ # (`_design_evidence`, stopspec's `Dc`) match it against the paths `_ui_writes`
249
+ # accepted - every UI target the row wrote, never the row's command - so a
250
+ # directory named here cannot pull in a file the same shell line merely reads
251
+ # (`cat templates/welcome.txt`).
192
252
  DESIGN_COMPONENT = re.compile(
193
- r"(?:^|/)(?:components?|views?|widgets?|partials?|screens?)/"
253
+ r"(?:^|/)(?:components?|views?|widgets?|partials?|screens?|templates?)/"
194
254
  r"|(?:^|/)(?-i:[A-Z])[A-Za-z0-9_]*\.(?:tsx|jsx|vue|svelte|swift|kt|dart)$"
255
+ r"|\.(?:vue|svelte|astro)$"
256
+ r"|(?:^|/)\+?(?:page|layout|template)\.(?:tsx|jsx)$"
195
257
  r"|\.(?:component|view|widget)\.[a-z]+$", re.I)
196
258
  # the explicit swallowers that make a failing check exit 0, the classic "I ran it
197
259
  # and it was fine": `|| true`, `|| :`, `|| exit 0`, `; true`, `; :` and
@@ -216,8 +278,10 @@ GITISH = re.compile(r"\b(?:git|commit|push|husky|pre-commit|npm|yarn|pnpm)\b", r
216
278
  # standalone `git config core.hooksPath .githooks`, a read or `--unset`, and a
217
279
  # hook install that merely names `pre-push` pass.
218
280
  # ponytail: a hooksPath set in one call and a commit in the next is not seen;
219
- # that needs the session's earlier calls, not one command line.
220
- HOOKS_KEY = "core.hookspath"
281
+ # that needs the session's earlier calls, not one command line. An include
282
+ # (`include.path`, `includeIf.<cond>.path`) counts as the key: the file it pulls
283
+ # in can set hooksPath (review 148 r2 #3).
284
+ HOOKS_KEYS = re.compile(r"core\.hookspath|include\.path|includeif\..+\.path")
221
285
  GIT_CONFIG_ENV = re.compile(r"GIT_CONFIG_(?:KEY_\d+|PARAMETERS)")
222
286
  ENV_WORD = re.compile(r"[A-Za-z_]\w*=")
223
287
  # git's global options that take the next word as their value
@@ -239,8 +303,10 @@ REDIRECTION = re.compile(r"^(?=[&<>]*[<>])[&<>]+$")
239
303
  # an fd number glued to its redirection (`2>`, `1<&`): bash reads `1<` as fd 1
240
304
  # and `1 <` as the program `1`, and shlex cannot tell them apart afterwards
241
305
  FD_REDIRECT = re.compile(r"(?<![\w])(\d+)([<>])")
242
- # what a shell line puts between the start of a command and its program; the
243
- # same set in the JS mirror, so the two read a line identically
306
+ # the bare wrappers `_bookkeeping_command` steps over (no options: a wrapper
307
+ # with arguments is not a plain read). The shortcut, neuter and control readers
308
+ # use WRAPPER_VALUE_OPTS instead; the opencode mirror's GIT_WRAPPER still reads
309
+ # its shortcut rules with this set until its port, which is plan 149's.
244
310
  GIT_WRAPPER = frozenset(("sudo", "env", "nohup", "time", "timeout", "command",
245
311
  "exec"))
246
312
  # tests disabled so a failure disappears; checked only when newly introduced
@@ -291,7 +357,9 @@ TESTED_WORD = re.compile(r"test|check|suite", re.I)
291
357
  # it now clears only a class whose repair is a check (ADMITTED_CLASSES), and
292
358
  # only when a check ran and failed after the turn's newest change
293
359
  # (`_failed_since_change`, applied by `_admitted`).
294
- NEGATED = re.compile(alts("negated"), re.I)
360
+ # A label at a line's start or a sentence about the change, never an
361
+ # incidental "untested" elsewhere in the reply (review 150 R2-1): re.M.
362
+ NEGATED = re.compile(alts("negated"), re.I | re.M)
295
363
  ADMITTED_CLASSES = frozenset(("check failed", "partial failure", "stale evidence",
296
364
  "no verify_ok"))
297
365
  # What the evidence classes tell the model the admission takes, in one place.
@@ -1237,6 +1305,12 @@ def scratch_target(path, cwd=None):
1237
1305
  return False
1238
1306
 
1239
1307
 
1308
+ # How long another session's unanswered `began` row holds its file before its
1309
+ # host must have been seen answering that tool (`writers_elsewhere`, plan 171):
1310
+ # about a write's own duration, so a refused prompt holds the file a minute.
1311
+ FRESH_BEGAN_SECONDS = 60
1312
+
1313
+
1240
1314
  def writers_elsewhere(path, session_id, minutes=10, cwd=None, lineage=False):
1241
1315
  """The other sessions that recorded a write of `path` in the last `minutes`,
1242
1316
  newest first, as ledger ids. With `lineage`, each one is a (ledger id,
@@ -1262,6 +1336,16 @@ def writers_elsewhere(path, session_id, minutes=10, cwd=None, lineage=False):
1262
1336
  parse is skipped. A cross-session rule that cannot see the other ledgers has
1263
1337
  learned nothing, and must stay silent rather than act on a guess.
1264
1338
 
1339
+ A `began` row of `path` that no outcome row of its `id` answered yet is a
1340
+ write in flight and counts as one (audit B-069, owner answer OQ-09): two
1341
+ sessions whose gates both ran before either write landed saw nothing. For
1342
+ its first FRESH_BEGAN_SECONDS it counts outright (owner answer 2026-10-31,
1343
+ plan 171): two sessions on their first write of one file. After that only
1344
+ for a tool that session's host is seen answering inside the window, the way
1345
+ `unanswered` reads it: a host that sends no result for a tool, or a call
1346
+ the user refused at its prompt before any answered one, would otherwise
1347
+ hold the file for the whole window.
1348
+
1265
1349
  ponytail: a shell write counts only through the target its `run` row
1266
1350
  carries (a redirect or `tee`, `tezgah_gate.write_paths`): a sibling that
1267
1351
  wrote the file through a positional target (`sed -i`, `cp`) is invisible
@@ -1270,9 +1354,10 @@ def writers_elsewhere(path, session_id, minutes=10, cwd=None, lineage=False):
1270
1354
  if not want:
1271
1355
  return []
1272
1356
  now = time.time()
1273
- found = {}
1357
+ found, waiting, delivered = {}, {}, set()
1274
1358
  try:
1275
- recent = ts.recent_rows(cache_dir(), now - minutes * 60, ("edit", "run"),
1359
+ recent = ts.recent_rows(cache_dir(), now - minutes * 60,
1360
+ tuple(sorted(OUTCOME_KINDS | {BEGAN_KIND})),
1276
1361
  _slug(session_id))
1277
1362
  except ts.ERRORS:
1278
1363
  return []
@@ -1283,8 +1368,26 @@ def writers_elsewhere(path, session_id, minutes=10, cwd=None, lineage=False):
1283
1368
  row = json.loads(text)
1284
1369
  except ValueError:
1285
1370
  continue
1286
- if isinstance(row, dict) and row.get("target") == want:
1371
+ if not isinstance(row, dict):
1372
+ continue
1373
+ key = (stem, row.get("id"))
1374
+ if row.get("kind") == BEGAN_KIND:
1375
+ if row.get("id"):
1376
+ waiting.setdefault(key, []).append(
1377
+ (row.get("tool"), row.get("target") == want, stamp))
1378
+ continue
1379
+ if row.get("id") and waiting.get(key):
1380
+ # one outcome answers one `began`, oldest first; a refusal shows
1381
+ # nothing about the host delivering results (`_began_fold`)
1382
+ tool, _mine, _ts = waiting[key].pop(0)
1383
+ if row.get("kind") not in REFUSAL_KINDS:
1384
+ delivered.add((stem, tool))
1385
+ if row.get("kind") in ("edit", "run") and row.get("target") == want:
1287
1386
  found[stem] = max(found.get(stem, stamp), stamp)
1387
+ for (stem, _id), rows in waiting.items():
1388
+ for tool, mine, stamp in rows:
1389
+ if mine and ((stem, tool) in delivered or now - stamp <= FRESH_BEGAN_SECONDS):
1390
+ found[stem] = max(found.get(stem, stamp), stamp)
1288
1391
  stems = sorted(found, key=lambda stem: -found[stem])
1289
1392
  return [(stem,) + _relation(stem, session_id) + (found[stem],)
1290
1393
  for stem in stems] if lineage else stems
@@ -1355,7 +1458,7 @@ def _prompt_key(text):
1355
1458
  return hashlib.sha1(text.encode("utf-8", "replace")).hexdigest()[:12]
1356
1459
 
1357
1460
 
1358
- def note_turn(session_id, prompt, workspace=None):
1461
+ def note_turn(session_id, prompt, workspace=None, subagent=False):
1359
1462
  """Write the user-turn marker the loop guard resets on, at most once per
1360
1463
  submission.
1361
1464
 
@@ -1378,7 +1481,8 @@ def note_turn(session_id, prompt, workspace=None):
1378
1481
  unanswered. The `turn` row is still written, since the Stop rule slices
1379
1482
  turns on every one of them, but marked `inflight`, and `_in_flight` reads
1380
1483
  a marked row as no boundary: a first forged run cannot close the window
1381
- for a second one.
1484
+ for a second one. A subagent's prompt (`subagent`) is its parent's brief,
1485
+ never the user's word, so it authorizes nothing either (B-086).
1382
1486
 
1383
1487
  ponytail: the same prompt re-sent as the very next thing, with no ledger row
1384
1488
  written in between, resets nothing - that direction can only deny too much,
@@ -1391,7 +1495,7 @@ def note_turn(session_id, prompt, workspace=None):
1391
1495
  inflight = _in_flight(session_id)
1392
1496
  named = [n for n in SWITCHES
1393
1497
  if re.search(r"(?<![\w.-])%s(?![\w-])" % re.escape(n), text)]
1394
- if named and not inflight:
1498
+ if named and not inflight and not subagent:
1395
1499
  note(session_id, "authorized", workspace=workspace, authorized=named)
1396
1500
  note(session_id, "turn", key, workspace=workspace, inflight=1 if inflight else None)
1397
1501
 
@@ -1424,13 +1528,16 @@ def _in_flight(session_id):
1424
1528
 
1425
1529
 
1426
1530
 
1427
- def bind_session(session_id):
1531
+ def bind_session(session_id, parent=None):
1428
1532
  """Latch this process's `off()` to `session_id`'s ledger (`tezgah_paths.latch`).
1429
1533
 
1430
1534
  Every hook entry that knows its session calls it before its first `off()`:
1431
1535
  the gate (`decision`), the prompt path (`context_for`) and the host
1432
- adapters' Stop paths. No session id leaves the process unbound."""
1433
- latch(_path(session_id) if session_id else None)
1536
+ adapters' Stop paths. No session id leaves the process unbound. `parent` is
1537
+ the payload's parent session: a subagent latches on the earlier of the two
1538
+ sessions' first rows (B-036)."""
1539
+ latch(_path(session_id) if session_id else None,
1540
+ _path(parent) if session_id and parent and parent != session_id else None)
1434
1541
 
1435
1542
 
1436
1543
  def note_compaction(session_id, summary, trigger=None, found=None, expected=None,
@@ -2119,12 +2226,6 @@ def _check_runs(cmd, cwd=None):
2119
2226
  yield from _repo_check_runs(cmd, prefixes)
2120
2227
 
2121
2228
 
2122
- # The words in front of a program that leave it the program: VERIFY's own
2123
- # wrapper prefix, read on words instead of text.
2124
- CHECK_WRAPPERS = frozenset(("sudo", "env", "time", "nohup", "command", "exec"))
2125
- ASSIGNMENT_WORD = re.compile(r"^[A-Za-z_]\w*=")
2126
-
2127
-
2128
2229
  def repo_checks(cwd):
2129
2230
  """[argv prefix] the repository at `cwd` declares as checks in its manifest
2130
2231
  (`tezgah_extension.repo_checks`), or [] - no cwd, no manifest, a broken one,
@@ -2141,20 +2242,15 @@ def repo_checks(cwd):
2141
2242
 
2142
2243
  def _repo_check_runs(cmd, prefixes):
2143
2244
  """(name, mode) for each simple command of `cmd` that starts with one of
2144
- `prefixes`, after assignments and VERIFY's wrappers. Read with
2245
+ `prefixes`, after assignments and the wrappers (`_past_wrappers`, the
2246
+ table VERIFY's prefix reads). Read with
2145
2247
  `_shell_commands`, so a prefix inside a quoted argument, a commit message or
2146
2248
  a QUOTED heredoc body (`<<'EOF'`, `<<"EOF"`) is an argument, not a run - the
2147
2249
  masked reading VERIFY keeps. An unquoted heredoc's body (`<<EOF`, `<<-EOF`)
2148
2250
  is read as commands, so `cat > x <<EOF` over a `./bin/run-tool` line counts
2149
- as a run, as a `pytest` line there does for VERIFY.
2150
- ponytail: a wrapper's own option (`sudo -u root ./bin/x`) hides the program,
2151
- a lost credit rather than an invented one."""
2251
+ as a run, as a `pytest` line there does for VERIFY."""
2152
2252
  for words, _sep in _shell_commands(cmd):
2153
- i = 0
2154
- while i < len(words) and (words[i] in CHECK_WRAPPERS
2155
- or ASSIGNMENT_WORD.match(words[i])):
2156
- i += 1
2157
- run = words[i:]
2253
+ run = _past_wrappers(words)
2158
2254
  if not run:
2159
2255
  continue
2160
2256
  head = [os.path.normpath(run[0])] + run[1:]
@@ -2298,6 +2394,13 @@ def _kept(ch, before, parens):
2298
2394
 
2299
2395
 
2300
2396
  def _heredocs(cmd):
2397
+ """`_heredocs_parse`, as a fresh list: the gate's readers ask for the same
2398
+ command several times in one call (plan 169), so the parse runs once."""
2399
+ return list(_heredocs_parse(cmd))
2400
+
2401
+
2402
+ @functools.lru_cache(maxsize=256)
2403
+ def _heredocs_parse(cmd):
2301
2404
  """Every heredoc operator in `cmd`, in order, as (operator start, operator
2302
2405
  end, tag, quoted, body start, terminator span or None, safe).
2303
2406
 
@@ -2662,6 +2765,13 @@ def _shell_lines(text):
2662
2765
 
2663
2766
 
2664
2767
  def _shell_commands(cmd):
2768
+ """`_shell_commands_parse`, as fresh lists: one parse per command string per
2769
+ process (plan 169); no caller can change what the next one reads."""
2770
+ return [[list(words), sep] for words, sep in _shell_commands_parse(str(cmd or ""))]
2771
+
2772
+
2773
+ @functools.lru_cache(maxsize=256)
2774
+ def _shell_commands_parse(cmd):
2665
2775
  """`_shell_segments` with each command's separator kept: [words, sep], where
2666
2776
  `sep` is the `;&|()` runs that ended it (a run after an empty command joins
2667
2777
  the previous one's) plus "\\n" at a line end, "" at the end of the line.
@@ -2765,10 +2875,71 @@ def _shell_segments(cmd):
2765
2875
 
2766
2876
 
2767
2877
  def _names_hooks_key(setting):
2768
- """True for `core.hooksPath` or `core.hooksPath=<value>`, any case. A leading
2769
- `$` is dropped: bash reads `$'core.hooksPath'` and `$"core.hooksPath"` as the
2770
- plain key, while shlex leaves the `$` on the word."""
2771
- return setting.lstrip("$").lower().partition("=")[0] == HOOKS_KEY
2878
+ """True for `core.hooksPath`, `include.path` or `includeIf.<cond>.path`
2879
+ (HOOKS_KEYS), bare or with `=<value>`, any case: an include can carry a
2880
+ hooksPath (review 148 r2 #3). A leading `$` is dropped: bash reads
2881
+ `$'core.hooksPath'` and `$"core.hooksPath"` as the plain key, while shlex
2882
+ leaves the `$` on the word."""
2883
+ return bool(HOOKS_KEYS.fullmatch(setting.lstrip("$").lower().partition("=")[0]))
2884
+
2885
+
2886
+ def _option_value(word, opts):
2887
+ """How a wrapper's dash word takes its value, the way getopt reads it:
2888
+ (letter, stuck) for a long option in `opts` or the first letter of a short
2889
+ cluster that is one (`-Hu`: `u`), `stuck` the rest of the word after it (""
2890
+ when the value is the next word); None when the word takes no value."""
2891
+ if word in opts:
2892
+ return word, ""
2893
+ if word.startswith("--") or len(word) < 2:
2894
+ return None
2895
+ for k, ch in enumerate(word[1:], 1):
2896
+ if "-" + ch in opts:
2897
+ return ch, word[k + 1:]
2898
+ return None
2899
+
2900
+
2901
+ def _past_wrappers(words, assigns=None):
2902
+ """`words` from a segment's program on: past env assignments, compound
2903
+ keywords (SHELL_KEYWORDS) and the wrappers (WRAPPER_VALUE_OPTS, by
2904
+ basename) with their options, their option values - a short option that
2905
+ closes a cluster takes the next word (`sudo -Hu deploy`, `timeout -vk 5`,
2906
+ review F1) - and `timeout`'s duration. `env -S STRING` (alone, in a
2907
+ cluster `-iS`, or `--split-string`) runs STRING split into words, which are
2908
+ read on as the segment's own (review m5). `assigns`, a list when given,
2909
+ collects the env assignments stepped over."""
2910
+ words = list(words)
2911
+ i = 0
2912
+ while i < len(words):
2913
+ word = words[i]
2914
+ wrapper = os.path.basename(word)
2915
+ if ENV_WORD.match(word):
2916
+ if assigns is not None:
2917
+ assigns.append(word)
2918
+ i += 1
2919
+ elif wrapper in WRAPPER_VALUE_OPTS:
2920
+ i += 1
2921
+ opts = WRAPPER_VALUE_OPTS[wrapper]
2922
+ while i < len(words) and words[i].startswith("-"):
2923
+ long_split = wrapper == "env" and re.fullmatch(
2924
+ r"--split-string(?:=(.*))?", words[i], re.S)
2925
+ hit = _option_value(words[i], opts)
2926
+ if long_split or (wrapper == "env" and hit and hit[0] in ("S", "-S")):
2927
+ value = long_split.group(1) if long_split else hit[1]
2928
+ if not value:
2929
+ value = words.pop(i + 1) if i + 1 < len(words) else ""
2930
+ try:
2931
+ words[i:i + 1] = shlex.split(value)
2932
+ except ValueError:
2933
+ words[i:i + 1] = value.split()
2934
+ continue
2935
+ i += 2 if hit and not hit[1] else 1
2936
+ if wrapper == "timeout" and i < len(words):
2937
+ i += 1 # the duration
2938
+ elif word in SHELL_KEYWORDS:
2939
+ i += 1
2940
+ else:
2941
+ break
2942
+ return words[i:]
2772
2943
 
2773
2944
 
2774
2945
  def _hooks_redirect(cmd):
@@ -2781,17 +2952,15 @@ def _hooks_redirect(cmd):
2781
2952
  denied a legitimate line."""
2782
2953
  assigns = writes = False
2783
2954
  for words in _shell_segments(cmd):
2784
- i = 0
2785
- while i < len(words) and (ENV_WORD.match(words[i])
2786
- or words[i] in GIT_WRAPPER):
2787
- if ENV_WORD.match(words[i]):
2788
- name, _, value = words[i].partition("=")
2789
- if GIT_CONFIG_ENV.fullmatch(name) and HOOKS_KEY in value.lower():
2790
- assigns = True
2791
- i += 1
2792
- if i >= len(words) or os.path.basename(words[i]) != "git":
2955
+ seen = []
2956
+ words = _past_wrappers(words, seen)
2957
+ for word in seen:
2958
+ name, _, value = word.partition("=")
2959
+ if GIT_CONFIG_ENV.fullmatch(name) and HOOKS_KEYS.search(value.lower()):
2960
+ assigns = True
2961
+ if not words or os.path.basename(words[0]) != "git":
2793
2962
  continue
2794
- i += 1
2963
+ i = 1
2795
2964
  sub = None
2796
2965
  while i < len(words):
2797
2966
  word = words[i]
@@ -2823,7 +2992,7 @@ def _hooks_redirect(cmd):
2823
2992
  # option (`core.hooksPath -- --unset` sets the value `--unset`)
2824
2993
  positional = ([a for a in head if not a.startswith("-")]
2825
2994
  + (args[args.index("--") + 1:] if "--" in args else []))
2826
- if not reads and len(positional) >= 2 and positional[0] == HOOKS_KEY:
2995
+ if not reads and len(positional) >= 2 and _names_hooks_key(positional[0]):
2827
2996
  assigns = True
2828
2997
  return assigns and writes
2829
2998
 
@@ -2886,13 +3055,10 @@ def _git_skips_hooks(cmd):
2886
3055
  (`--no-verif`, `--no-ver`). Both passed the gate (audit SEC-03 / L-5).
2887
3056
  `-n` is read for `commit` only: on `push` it is --dry-run."""
2888
3057
  for words in _shell_segments(cmd):
2889
- i = 0
2890
- while i < len(words) and (ENV_WORD.match(words[i])
2891
- or words[i] in GIT_WRAPPER):
2892
- i += 1
2893
- if i >= len(words) or os.path.basename(words[i]) != "git":
3058
+ words = _past_wrappers(words)
3059
+ if not words or os.path.basename(words[0]) != "git":
2894
3060
  continue
2895
- i += 1
3061
+ i = 1
2896
3062
  while i < len(words) and words[i].startswith("-"):
2897
3063
  i += 2 if words[i] in GIT_VALUE_OPTS else 1
2898
3064
  if i >= len(words):
@@ -2925,9 +3091,10 @@ def _git_skips_hooks(cmd):
2925
3091
 
2926
3092
 
2927
3093
  def _shell_scripts(cmd):
2928
- """The script text of every `bash -c '...'` / `sh -c` segment: the masked
2929
- scan below blanks a quoted script, so a check neutered or a hook skipped
2930
- inside one passed unseen (audit SEC-03 / L-5).
3094
+ """The script text of every `bash -c '...'` / `sh -c` segment, and of every
3095
+ `eval` (its words joined): the masked scan below blanks a quoted script, so
3096
+ a check neutered or a hook skipped inside one passed unseen (audit SEC-03 /
3097
+ L-5, B-014).
2931
3098
 
2932
3099
  Read the way bash reads its own options: the script is the first word that
2933
3100
  is not an option, once `-c` has been seen. Long options are skipped (and
@@ -2936,13 +3103,16 @@ def _shell_scripts(cmd):
2936
3103
  `sh -e -c '...'` are unwrapped too (review F10)."""
2937
3104
  out = []
2938
3105
  for words in _shell_segments(cmd):
2939
- i = 0
2940
- while i < len(words) and (ENV_WORD.match(words[i])
2941
- or words[i] in GIT_WRAPPER):
2942
- i += 1
2943
- if i >= len(words) or os.path.basename(words[i]) not in SHELLS:
3106
+ words = _past_wrappers(words)
3107
+ if words[:1] == ["eval"]:
3108
+ # eval runs its words, joined by spaces, as a script (audit B-014);
3109
+ # a leading `--` only ends its options (review m3)
3110
+ args = words[1:]
3111
+ out.append(" ".join(args[1:] if args[:1] == ["--"] else args))
3112
+ continue
3113
+ if not words or os.path.basename(words[0]) not in SHELLS:
2944
3114
  continue
2945
- seen, j = False, i + 1
3115
+ seen, j = False, 1
2946
3116
  while j < len(words):
2947
3117
  word = words[j]
2948
3118
  if word in SHELL_VALUE_LONGS:
@@ -2973,8 +3143,9 @@ def _shortcut_command(cmd, depth):
2973
3143
  "(SKIP=/HUSKY_SKIP_HOOKS/HUSKY=0) turns the checks off. Run them "
2974
3144
  "instead of disabling them.")
2975
3145
  if _hooks_redirect(cmd):
2976
- return ("Verification bypass denied: `core.hooksPath` is redirected in "
2977
- "the same command as a commit/push, so git runs a hooks directory "
3146
+ return ("Verification bypass denied: `core.hooksPath` (or an include that "
3147
+ "can set it) is redirected in the same command as a commit/push, so "
3148
+ "git runs a hooks directory "
2978
3149
  "that does not hold the checks - the same skip as `--no-verify`. "
2979
3150
  "Run the checks, fix what they report, and commit without it.")
2980
3151
  if verify_command(c) and NEUTER.search(c):
@@ -2982,6 +3153,11 @@ def _shortcut_command(cmd, depth):
2982
3153
  "`; true`, so it reports success no matter what it found. Run it "
2983
3154
  "as the line's last command, with nothing after it but `&&`, and "
2984
3155
  "read the real exit status before claiming it passed.")
3156
+ if verify_command(c) and exit_trap_swallows(cmd):
3157
+ return ("Verification neutered: a trap that runs `exit 0` ends the "
3158
+ "shell with status 0 whatever this check found. Drop the trap, "
3159
+ "run the check as the line's last command and read its real exit "
3160
+ "status before claiming it passed.")
2985
3161
  if depth < SHELL_DEPTH:
2986
3162
  for script in _shell_scripts(cmd):
2987
3163
  reason = _shortcut_command(script, depth + 1)
@@ -2994,8 +3170,61 @@ def _shortcut_command(cmd, depth):
2994
3170
  # is then its first failing stage's, so a check piped through `tail` keeps its
2995
3171
  # own exit and the host's verdict is the check's.
2996
3172
  PIPEFAIL = re.compile(r"^\s*set\s+(?:-\w+\s+)*-\w*o\s+pipefail\s*(?:;|&&|\n)")
2997
- # the stages that trim or filter what a check printed; `|&` pipes stderr too
2998
- TRIMMER = re.compile(r"^\s*&?\s*(tail|head|e?grep|fgrep|cut|wc|sed\s+-n)\b")
3173
+ # the stages that trim or filter what a check printed; `|&` pipes stderr too.
3174
+ # A pager, `sort`, `cat` and a `tee` own the line's status just as `tail` does
3175
+ # (audit B-070).
3176
+ TRIMMER = re.compile(
3177
+ r"^\s*&?\s*(tail|head|e?grep|fgrep|rg|awk|cut|wc|sed|sort|cat|less|more|tee)\b")
3178
+
3179
+
3180
+ def exit_trap_swallows(cmd):
3181
+ """True when the line sets a trap that runs `exit 0` (`trap 'exit 0'
3182
+ EXIT`), on any signal: bash then ends with the trap's status, so a failing
3183
+ check exits 0 (audit B-004) - on EXIT after it, on ERR when it fails, on
3184
+ DEBUG before it even runs (review M1). A trap that only cleans up (`trap
3185
+ 'rm -f x' EXIT`) keeps the check's status and passes."""
3186
+ for words in _shell_segments(cmd):
3187
+ words = _past_wrappers(words)
3188
+ if words[:1] != ["trap"]:
3189
+ continue
3190
+ args = [w for w in words[1:] if w != "--"]
3191
+ if len(args) >= 2 and re.search(r"(?:^|[\s;&|(])exit\s+0+\b", args[0]):
3192
+ return True
3193
+ return False
3194
+
3195
+
3196
+ # What a trap may run and still leave the check's status the line's: an EXIT
3197
+ # trap whose every command is plain cleanup. Any other action can end the shell
3198
+ # with a status of its own (`exit $((0))`, `exec true`, `ex""it 0`, a function),
3199
+ # and an ERR, DEBUG or RETURN trap runs in the middle of the check, so
3200
+ # `status_hidden` records such a line as ran, never as passed (review m4, F2).
3201
+ # A `trap` word the line's own commands do not set - inside an `eval` or
3202
+ # `bash -c` string, a function body, a commit message - counts the same way.
3203
+ # The traps are the parsed segments', so a quoted or escaped spelling (`t''rap`,
3204
+ # `t\rap`) is one too; a `$'...'` on a trap line counts as an action of its own,
3205
+ # since the word reader does not decode its escapes (review 148 r2 #4).
3206
+ # ponytail: a trap set by an earlier call is not seen, and a line that only
3207
+ # names the word records as ran - a lost credit, never an invented one.
3208
+ TRAP_WORD = re.compile(r"(?<![\w.-])trap(?![\w.-])")
3209
+ TRAP_CLEANUP = frozenset(("rm", "echo", "printf", ":"))
3210
+
3211
+
3212
+ def trap_owns_status(cmd):
3213
+ """True when a trap on this line may own its exit status (TRAP_CLEANUP)."""
3214
+ cmd = str(cmd or "")
3215
+ traps = [w for w in map(_past_wrappers, _shell_segments(cmd)) if w[:1] == ["trap"]]
3216
+ if len(traps) < len(TRAP_WORD.findall(cmd)) or traps and "$'" in cmd:
3217
+ return True
3218
+ for words in traps:
3219
+ args = [w for w in words[1:] if w != "--"]
3220
+ if len(args) < 2 or args[0] == "-":
3221
+ continue # a listing (`trap -p`) or a reset
3222
+ if any(s.upper().removeprefix("SIG") not in ("EXIT", "0") for s in args[1:]):
3223
+ return True
3224
+ if any(not w or w[0] not in TRAP_CLEANUP
3225
+ for w in _shell_segments(args[0])):
3226
+ return True
3227
+ return False
2999
3228
 
3000
3229
 
3001
3230
  def pipe_hides_status(cmd):
@@ -3025,11 +3254,15 @@ def status_hidden(cmd, cwd=None):
3025
3254
  status. Read with `_shell_commands`, each command re-quoted for
3026
3255
  `verify_command`; `cwd` reads the repository's declared checks
3027
3256
  (`repo_checks`) as checks too, so `./bin/run-tool x; echo done` is hidden
3028
- the way `pytest; echo done` is. ponytail: `set -e` is not read, so `set -e;
3029
- pytest; echo done` records as ran - a lost credit, never an invented one."""
3030
- if pipe_hides_status(cmd):
3031
- return True
3257
+ the way `pytest; echo done` is. A trap that may end the shell on its own
3258
+ (`trap_owns_status`) owns the status too, and so does an `exit` or a
3259
+ command-running `exec` before the check (`exit 0; pytest` never runs it;
3260
+ a bare `exec >log` only redirects). ponytail: `set -e` is not read, so
3261
+ `set -e; pytest; echo done` records as ran - a lost credit, never an
3262
+ invented one; `builtin exit` and `command exec` are not read."""
3032
3263
  cmd = str(cmd or "")
3264
+ if pipe_hides_status(cmd) or trap_owns_status(cmd):
3265
+ return True
3033
3266
  body = _heredoc_bytes(cmd)
3034
3267
  cmds = _shell_commands("".join(" " if b and ch != "\n" else ch
3035
3268
  for ch, b in zip(cmd, body)))
@@ -3039,6 +3272,9 @@ def status_hidden(cmd, cwd=None):
3039
3272
  if not (verify_command(line) or any(
3040
3273
  mode == "check" for _n, mode in _repo_check_runs(line, prefixes))):
3041
3274
  continue
3275
+ if any(w[:1] == ["exit"] or w[:1] == ["exec"] and len(w) > 1
3276
+ for w, _s in cmds[:i]):
3277
+ return True
3042
3278
  for k in range(i, len(cmds) - 1):
3043
3279
  if OWNING_SEP.match(cmds[k][1]):
3044
3280
  continue
@@ -4042,7 +4278,9 @@ def ran_nothing(result):
4042
4278
  # pre-existing or environmental still reads as a claim: it names no conflict.
4043
4279
  # `claims` is the false-done reader the benchmarks count with; the Stop verdict
4044
4280
  # and its claim row read the vocabulary (`asserted_claims`), where an admitted
4045
- # claim is a claim NEGATED lets through.
4281
+ # claim is a claim NEGATED lets through. Both read a claim word through the
4282
+ # same question/negation window (`_asserted`): "I have not fixed it yet" is no
4283
+ # completion, like its twin "henüz düzeltmedim" (B-052).
4046
4284
  ADMITTED = re.compile(alts("admitted"), re.I)
4047
4285
 
4048
4286
 
@@ -4051,7 +4289,7 @@ def claims(text):
4051
4289
  t = str(text or "")
4052
4290
  if NEGATED.search(t) or ADMITTED.search(t):
4053
4291
  return (False, False)
4054
- return (bool(DONE.search(t)), bool(VERIFIED.search(t)))
4292
+ return asserted_claims(t)
4055
4293
 
4056
4294
 
4057
4295
  # The question/negation window around a claim word (R04 i, evidence-10): "is
@@ -4064,8 +4302,14 @@ def claims(text):
4064
4302
  # all still assert.
4065
4303
  NEGATION_BEFORE = re.compile(r"(?:%s)(?:\s+\S+){0,2}\s+\Z" % alts("negation_before"),
4066
4304
  re.I)
4305
+ # "if/when the tests pass" makes a completion conditional, but "when I ran the
4306
+ # tests" still reports the run: the condition window is read for DONE only
4307
+ # (review 150 R2-2).
4308
+ CONDITION_BEFORE = re.compile(r"(?:%s)(?:\s+\S+){0,2}\s+\Z" % alts("condition_before"),
4309
+ re.I)
4067
4310
  QUESTION_PARTICLE = re.compile(r"\s*(?:%s)\b" % alts("question_particle"), re.I)
4068
- CLAUSE_END = re.compile(r"[.!?\n,;:\u2014\u2013()]|\b(?:%s)\b" % alts("clause_join"),
4311
+ # a `.` inside a number ("Python 3.12") ends no clause (review 150 F3)
4312
+ CLAUSE_END = re.compile(r"\.(?!\d)|[!?\n,;:\u2014\u2013()]|\b(?:%s)\b" % alts("clause_join"),
4069
4313
  re.I)
4070
4314
  # The first-person claim that a command or a check was run ("test ettim", "i
4071
4315
  # ran the suite"): the one claim a read-only turn cannot have earned, read by
@@ -4074,17 +4318,21 @@ CLAUSE_END = re.compile(r"[.!?\n,;:\u2014\u2013()]|\b(?:%s)\b" % alts("clause_jo
4074
4318
  RAN_IT = re.compile(alts("ask", "ran_it"), re.I)
4075
4319
 
4076
4320
 
4077
- def _asserted(pattern, text):
4321
+ def _asserted(pattern, text, within=None):
4078
4322
  """The first match of `pattern` in `text` that is neither inside a question
4079
- nor under a negation of its own clause, else None. ponytail: a window of
4080
- words, not a parser."""
4323
+ nor under a negation of its own clause, else None; with `within`, only a
4324
+ match whose own text holds it counts. ponytail: a window of words, not a
4325
+ parser."""
4081
4326
  t = str(text or "")
4082
4327
  for m in pattern.finditer(t):
4328
+ if within and not within.search(m.group(0)):
4329
+ continue
4083
4330
  before = CLAUSE_END.split(t[max(0, m.start() - 80):m.start()])[-1]
4084
4331
  end = CLAUSE_END.search(t, m.end())
4085
4332
  asked = (QUESTION_PARTICLE.match(t, m.end())
4086
4333
  or (end and end.group(0) == "?"))
4087
- if not (NEGATION_BEFORE.search(before) or asked):
4334
+ if not (NEGATION_BEFORE.search(before) or asked
4335
+ or (pattern is DONE and CONDITION_BEFORE.search(before))):
4088
4336
  return m
4089
4337
  return None
4090
4338
 
@@ -4328,16 +4576,33 @@ def _ui_write(row):
4328
4576
  counts the row as a change. The kind test above is what the two agree on; the
4329
4577
  target of a command longer than the cut is this reader's to lose, and
4330
4578
  recovering it would mean walking the snapshot rows by call id for a path the
4331
- command already carried."""
4579
+ command already carried.
4580
+
4581
+ The first UI target is the answer; `_ui_writes` has the rest."""
4582
+ found = _ui_writes(row)
4583
+ return found[0] if found else None
4584
+
4585
+
4586
+ def _ui_writes(row):
4587
+ """Every UI source this row wrote, first one first: a shell line can redirect
4588
+ into several files (`echo a > app.css; echo b > components/Button.tsx`), and
4589
+ the gate's `write_paths` names the first target only. The rest come from
4590
+ `tezgah_gate.shell_targets`, the gate's own per-target reader, so this is
4591
+ still not a second shell parser. Before plan 144's review only the first
4592
+ target was read: a UI source written after a non-UI one owed no proof at
4593
+ all, and a component written after a screen owed no design check."""
4332
4594
  if not _change_row(row):
4333
- return None
4595
+ return []
4334
4596
  detail = str(row.get("detail") or "")
4335
4597
  if row.get("kind") != "run":
4336
- return detail if UI_PATH.search(detail) else None
4337
- for target in _written_paths({"command": detail}):
4338
- if UI_PATH.search(str(target)):
4339
- return str(target)
4340
- return None
4598
+ return [detail] if UI_PATH.search(detail) else []
4599
+ targets = [str(t) for t in _written_paths({"command": detail})]
4600
+ try:
4601
+ from tezgah_gate import shell_targets
4602
+ targets += [str(t) for t in shell_targets(detail)]
4603
+ except ImportError:
4604
+ pass
4605
+ return [t for t in dict.fromkeys(targets) if UI_PATH.search(t)]
4341
4606
 
4342
4607
 
4343
4608
  def _screen_read(row):
@@ -4442,7 +4707,7 @@ def _design_evidence(rows, cwd=None):
4442
4707
  component, check = -1, -1
4443
4708
  for i, row in enumerate(rows):
4444
4709
  detail = mask(str(row.get("detail") or ""))
4445
- if _ui_write(row) and DESIGN_COMPONENT.search(detail):
4710
+ if any(DESIGN_COMPONENT.search(p) for p in _ui_writes(row)):
4446
4711
  component = i
4447
4712
  if passing_check(row) and DESIGN_CHECK.search(detail):
4448
4713
  if DESIGN_SLOP.search(detail) and not _slop_row_holds(row, cwd):
@@ -4979,9 +5244,12 @@ def stop_reason(text, session_id, cwd=None, after_block=0,
4979
5244
  kind, reason = "subagent_end" if subagent else "after_block", None
4980
5245
  elif reason:
4981
5246
  # an external-state claim is a claim when it is refused: the class it
4982
- # owes (`no external read`) is about the claim, not the turn's work
5247
+ # owes (`no external read`) is about the claim, not the turn's work;
5248
+ # so is a plan claim, whose trigger is the run claim (RAN_IT), not
5249
+ # DONE/VERIFIED - "çalıştırdım" is in neither (B-073)
4983
5250
  detail = "blocked: %s" % cls
4984
- kind = ("claim" if claimed or _external_claim(str(text or ""))
5251
+ kind = ("claim" if claimed or cls == "plan claim"
5252
+ or _external_claim(str(text or ""))
4985
5253
  else "refusal")
4986
5254
  elif claimed:
4987
5255
  detail, kind = "ok", "claim"
@@ -5530,9 +5798,11 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None,
5530
5798
  # failure at all (the row carries no `exit`).
5531
5799
  # A prose edit (`_prose_edit`) is no step a check is owed for - unless the
5532
5800
  # reply says something was tested or checked, a claim that rests on a pass.
5801
+ # The claim is read through the question/negation window, as in a turn
5802
+ # with no step: "Testler geçti mi?" names a pass without claiming it (B-032).
5533
5803
  worked = {str(r.get("kind")) for r in rows if _work_row(r)}
5534
5804
  if not worked and any(_prose_edit(r) for r in rows) and (
5535
- verified or any(TESTED_WORD.search(m.group(0)) for m in DONE.finditer(t))):
5805
+ _asserted(VERIFIED, t) or _asserted(DONE, t, TESTED_WORD)):
5536
5806
  worked = {"edit"}
5537
5807
  # A turn whose steps are only reads - every simple command a known reader
5538
5808
  # or a git read (`_bookkeeping_turn` with GIT_READS), no change row - is
@@ -5664,7 +5934,7 @@ def _evidence_text(cls, rows, external, where="this turn"):
5664
5934
  `where` names the span the rows cover."""
5665
5935
  if cls == "check failed":
5666
5936
  return ("check failed",
5667
- "A check failed in %s and the reply claims success. "
5937
+ "A check failed in %s and no check ran after it. "
5668
5938
  "Report the failure with its exact error line, or fix it and "
5669
5939
  "re-run; do not describe a failed check as passing. %s."
5670
5940
  % (where, ADMISSION))