@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.
- package/CHANGELOG.md +175 -1
- package/MANIFEST +18 -0
- package/NOTICE +152 -0
- package/README.de.md +41 -20
- package/README.es.md +39 -19
- package/README.fr.md +41 -20
- package/README.md +15 -8
- package/README.tr.md +39 -21
- package/bin/codegen +9 -2
- package/bin/consult +6 -0
- package/bin/tezgah-adhd +3 -0
- package/bin/tezgah-agents +3 -0
- package/bin/tezgah-capture +3 -0
- package/bin/tezgah-design +329 -54
- package/bin/tezgah-docs +157 -41
- package/bin/tezgah-dsh +1 -1
- package/bin/tezgah-gate +7 -2
- package/bin/tezgah-import-ai-research +16 -4
- package/bin/tezgah-index +3 -0
- package/bin/tezgah-lessons +13 -4
- package/bin/tezgah-mcp +4 -0
- package/bin/tezgah-migrate +3 -0
- package/bin/tezgah-ocr-review +2 -1
- package/bin/tezgah-optimize +4 -1
- package/bin/tezgah-pony +3 -0
- package/bin/tezgah-research +12 -6
- package/bin/tezgah-rollback +3 -0
- package/bin/tezgah-route +1 -1
- package/bin/tezgah-setup +596 -157
- package/bin/tezgah-status +11 -1
- package/bin/tezgah-task +3 -0
- package/bin/tezgah.js +10 -3
- package/commands/adhd.md +1 -1
- package/docs/README.md +19 -8
- package/docs/architecture.md +2 -2
- package/docs/citations-baseline.json +11 -17
- package/docs/clarity-baseline.json +3 -3
- package/docs/contract.md +77 -31
- package/docs/environment.md +58 -24
- package/docs/evidence.md +49 -19
- package/docs/gate.md +71 -21
- package/docs/hosts.md +65 -52
- package/docs/layers.md +1 -1
- package/docs/operations.md +67 -24
- package/docs/research.md +1 -1
- package/docs/skills.md +31 -8
- package/docs/status-line.md +33 -23
- package/docs/testing.md +48 -17
- package/docs/vision.md +6 -2
- package/hooks/lang/en.json +5 -2
- package/hooks/lang/tr.json +3 -2
- package/hooks/projects-posttooluse.py +11 -3
- package/hooks/projects-stop.py +1 -1
- package/hooks/tezgah_context.py +40 -23
- package/hooks/tezgah_gate.py +404 -106
- package/hooks/tezgah_integrity.py +367 -97
- package/hooks/tezgah_lessons.py +5 -2
- package/hooks/tezgah_paths.py +17 -6
- package/hooks/tezgah_policy.py +13 -5
- package/hooks/tezgah_skill_pick.py +1 -1
- package/hooks/tezgah_snapshot.py +6 -3
- package/hooks/tezgah_stopspec.py +2 -1
- package/hosts/amp/hook.py +1 -1
- package/hosts/amp/tezgah-plugin.ts.in +13 -3
- package/hosts/antigravity/hook.py +1 -1
- package/hosts/codex/hook.py +1 -1
- package/hosts/commandcode/hook.py +1 -1
- package/hosts/copilot/hook.py +1 -1
- package/hosts/cursor/hook.py +2 -1
- package/hosts/droid/hook.py +1 -1
- package/hosts/gemini/hook.py +1 -1
- package/hosts/goose/hook.py +1 -1
- package/hosts/omp/hook.py +1 -1
- package/hosts/omp/tezgah-hook.ts.in +127 -35
- package/hosts/opencode/plugins/tezgah.js +209 -63
- package/hosts/qwen/hook.py +1 -1
- package/output-styles/tezgah.md +1 -1
- package/package.json +6 -3
- package/packaging/build.sh +4 -4
- package/packaging/install.sh +11 -0
- package/packaging/upgrade.sh +3 -0
- package/skills/ai-research/LICENSE +21 -0
- package/skills/design-contract/SKILL.md +27 -6
- package/skills/design-library/LICENSE +21 -0
- package/skills/plan-add/render_table.py +7 -0
- package/skills/pm-frameworks/LICENSE +21 -0
- package/skills/rl-env/LICENSE +201 -0
- package/skills/tezgah-contract/SKILL.md +2 -2
- package/third_party/open-code-review/LICENSE +201 -0
- package/third_party/open-code-review/README.md +15 -0
- package/third_party/open-code-review/prompts/main_task_system.md +26 -0
- package/third_party/open-code-review/prompts/review_filter_task_system.md +13 -0
- 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
|
|
49
|
-
# without it they would be refused rather than
|
|
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,
|
|
188
|
-
# own (`Button.tsx`, `users.component.ts`, `user_card.dart`)
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
243
|
-
#
|
|
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
|
-
|
|
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,
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 `
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
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
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
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
|
|
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]
|
|
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
|
-
|
|
2890
|
-
|
|
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
|
|
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
|
|
2929
|
-
scan below blanks a quoted script, so
|
|
2930
|
-
inside one passed unseen (audit SEC-03 /
|
|
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
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
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,
|
|
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`
|
|
2977
|
-
"the same command as a commit/push, so
|
|
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
|
-
|
|
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.
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
4080
|
-
words, not a
|
|
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
|
|
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
|
|
4337
|
-
for
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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))
|