@r1z4x/tezgah 1.7.0 → 1.8.3
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 +74 -1
- package/MANIFEST +2 -0
- package/bin/tezgah-decisions +6 -14
- package/bin/tezgah-design +21 -5
- package/bin/tezgah-lessons +4 -1
- package/bin/tezgah-research +15 -7
- package/bin/tezgah-route +22 -7
- package/bin/tezgah-task +320 -75
- package/bin/tezgah-taste +8 -5
- package/docs/architecture.md +24 -7
- package/docs/environment.md +3 -1
- package/docs/evidence.md +9 -1
- package/docs/gate.md +24 -7
- package/docs/models.md +40 -12
- package/docs/operations.md +31 -1
- package/docs/research.md +3 -2
- package/docs/testing.md +18 -11
- package/hooks/projects-auto-init.py +29 -1
- package/hooks/tezgah_agents.py +24 -7
- package/hooks/tezgah_context.py +71 -35
- package/hooks/tezgah_gate.py +430 -64
- package/hooks/tezgah_integrity.py +108 -41
- package/hooks/tezgah_models.py +90 -30
- package/hooks/tezgah_paths.py +72 -6
- package/hooks/tezgah_policy.py +3 -3
- package/hooks/tezgah_research.py +9 -5
- package/hooks/tezgah_task.py +319 -67
- package/hooks/tezgah_taste.py +8 -4
- package/hosts/codex/hook.py +23 -3
- package/hosts/cursor/hook.py +11 -0
- package/hosts/opencode/plugins/tezgah.js +171 -4
- package/package.json +1 -1
- package/skills/plan-add/SKILL.md +23 -9
- package/skills/plan-add/render_table.py +8 -3
- package/skills/tezgah-contract/SKILL.md +3 -3
package/hooks/tezgah_gate.py
CHANGED
|
@@ -102,7 +102,8 @@ from tezgah_integrity import (BASH_TOOLS, BEGAN_KIND, ENV_WORD, GIT_VALUE_OPTS,
|
|
|
102
102
|
HEREDOC_RISK, HOOKS_KEY, PATCH_FILE,
|
|
103
103
|
PLAIN_TAIL, SECRET_PREFIXED, STEP_KINDS,
|
|
104
104
|
WRITE_TOOLS, _blank_heredocs, _heredocs, bind_session,
|
|
105
|
-
_shell_lines, _shell_segments,
|
|
105
|
+
_shell_commands, _shell_lines, _shell_segments,
|
|
106
|
+
_turn_start,
|
|
106
107
|
_unquoted_backticks, call_id, cut, events,
|
|
107
108
|
heredoc_bodies, mask, mcp_class, mcp_text, note,
|
|
108
109
|
prior_calls, scratch_target, shortcut_command,
|
|
@@ -110,7 +111,7 @@ from tezgah_integrity import (BASH_TOOLS, BEGAN_KIND, ENV_WORD, GIT_VALUE_OPTS,
|
|
|
110
111
|
write_texts)
|
|
111
112
|
from tezgah_integrity import SUBAGENT_TOOLS, TODO_TOOLS, newest_todo, ready_heads
|
|
112
113
|
from tezgah_paths import (CACHE, CONFIG_DIR, HOST_DIRS, OFF_DIRS, PLUGIN_ROOT,
|
|
113
|
-
REPO_MARKS, SWITCHES, cache_dir, fallback_cache,
|
|
114
|
+
REPO_MARKS, SWITCHES, _toplevel, cache_dir, fallback_cache,
|
|
114
115
|
linked_main, off, root_for, roots)
|
|
115
116
|
|
|
116
117
|
try: # The ordering rule's two readers (the newest check's state, folded the way
|
|
@@ -267,6 +268,46 @@ SECRET_SINK = re.compile(
|
|
|
267
268
|
# log` hands the piped text to tee. The split is read off the masked text, so a
|
|
268
269
|
# quoted `;` does not split.
|
|
269
270
|
SEGMENT = re.compile(r"\|\||&&|[;&\n]")
|
|
271
|
+
# Plan 096: a token in a leading `NAME=value` word (after an optional `env`)
|
|
272
|
+
# reaches only the program's own environment, so that program's redirected
|
|
273
|
+
# output carries no key. The value reads quotes, one level of `$( )` and a
|
|
274
|
+
# backtick body; a value it cannot read ends the prefix early, and the token
|
|
275
|
+
# then sits outside it and holds. Names are ASCII, as bash reads them.
|
|
276
|
+
SECRET_PREFIX = re.compile(
|
|
277
|
+
r"[\s;&|]*(?:env\s+)?(?:[A-Za-z_]\w*=(?:'[^']*'|\"(?:\\.|[^\"\\])*\"|"
|
|
278
|
+
r"\$\([^()]*\)|`[^`]*`|\\.|[^\s;&|()<>'\"`\\])*\s+)+", re.ASCII)
|
|
279
|
+
# The sinks that carry a program's output, and nothing but it: the only ones a
|
|
280
|
+
# key in that program's environment stays out of.
|
|
281
|
+
SECRET_OUTPUT = re.compile(r">>?(?![&=])|\|\s*tee\b")
|
|
282
|
+
# The ceiling of that relaxation is an allowlist (`_secret_runner`): a script
|
|
283
|
+
# runner given a script file, with no option that makes it run its own text,
|
|
284
|
+
# stdin or a preload, or trace the commands it runs. Every other program may
|
|
285
|
+
# print its environment, so it holds. The rule cannot read the script itself:
|
|
286
|
+
# an allowlisted script that prints its own environment still writes the key.
|
|
287
|
+
SECRET_RUNNER = re.compile(r"python[\d.]*|node|bun|deno|bash|sh|zsh")
|
|
288
|
+
SECRET_SCRIPT = re.compile(r"[^$`\s]+\.(?:py|js|mjs|ts|sh)")
|
|
289
|
+
SECRET_INLINE = {"python": re.compile(r"-[A-Za-z]*[cmi]"),
|
|
290
|
+
"sh": re.compile(r"-[A-Za-z]*[csxv]|-o$|--(?:verbose|debugger)"),
|
|
291
|
+
"js": re.compile(r"-[A-Za-z]*[epri]|--(?:eval|print|require|"
|
|
292
|
+
r"import|loader|experimental-loader|preload|"
|
|
293
|
+
r"interactive)(?:=|$)")}
|
|
294
|
+
# A line that reads the environment from /proc, sets a variable an interpreter
|
|
295
|
+
# reads at startup or a shell trace, turns the shell's own trace on, or defines
|
|
296
|
+
# a function that can shadow the program, gets no relaxation.
|
|
297
|
+
SECRET_ENVIRON = re.compile(r"/proc/[^/\s]*/environ")
|
|
298
|
+
SECRET_STARTUP = re.compile(
|
|
299
|
+
r"(?<![\w$])(?:SHELLOPTS|BASHOPTS|BASH_ENV|ENV|PS4|ZDOTDIR|NODE_OPTIONS|"
|
|
300
|
+
r"PYTHONSTARTUP|PYTHONINSPECT|PERL5OPT|PERL5DB|RUBYOPT|LD_PRELOAD|DYLD_\w*)=",
|
|
301
|
+
re.ASCII)
|
|
302
|
+
SECRET_TRACE = re.compile(
|
|
303
|
+
r"(?:^|[\s;&|(])set\s+(?:-[A-Za-z]*[xv]|-o\s+(?:xtrace|verbose))")
|
|
304
|
+
SECRET_FUNCTION = re.compile(
|
|
305
|
+
r"(?:^|[\s;&|(])(?:function\s+[^\s(){};&|]+|[^\s(){};&|$=]+\s*\(\s*\))")
|
|
306
|
+
# The programs a carried name is printed by when they own a sink. An
|
|
307
|
+
# interpreter running its own text is read off `_program`, which names the
|
|
308
|
+
# interpreter only then; a program the shell names at run time prints too.
|
|
309
|
+
SECRET_PRINTERS = frozenset(("echo", "printf", "cat", "eval", "awk", "perl",
|
|
310
|
+
"ruby"))
|
|
270
311
|
|
|
271
312
|
SECRET_DENY = (
|
|
272
313
|
"Credential write denied: this command would land a credential in a file "
|
|
@@ -795,8 +836,14 @@ def secret_command(command):
|
|
|
795
836
|
|
|
796
837
|
Reading an env var or running a tool with a key in its env is the normal work
|
|
797
838
|
this must not touch, so a token only counts next to a write sink, and a sink
|
|
798
|
-
only carries the text of its own simple command. A
|
|
799
|
-
|
|
839
|
+
only carries the text of its own simple command. A token held only by the
|
|
840
|
+
leading `NAME=value` words of an allowlisted program is in that program's
|
|
841
|
+
env (`_env_prefix_only`), unless the line reads /proc's environ, sets a
|
|
842
|
+
startup or trace variable, turns `set -x` on or defines a function. A name
|
|
843
|
+
an earlier command assigned a token is carried, and a
|
|
844
|
+
printer owning a sink that reads it still writes it (`_printer_owns_sink`).
|
|
845
|
+
A `git add` that names a credential file is refused whatever its text holds
|
|
846
|
+
(`_secret_add`)."""
|
|
800
847
|
c = str(command or "")
|
|
801
848
|
added = _secret_add(c)
|
|
802
849
|
if added:
|
|
@@ -804,15 +851,97 @@ def secret_command(command):
|
|
|
804
851
|
if not c or not SECRET_TOKEN.search(c):
|
|
805
852
|
return None
|
|
806
853
|
masked = mask(c)
|
|
807
|
-
|
|
854
|
+
relax = not (SECRET_ENVIRON.search(c) or SECRET_STARTUP.search(c)
|
|
855
|
+
or SECRET_FUNCTION.search(masked) or SECRET_TRACE.search(masked))
|
|
856
|
+
start, carried = 0, set()
|
|
808
857
|
for end in [m.start() for m in SEGMENT.finditer(masked)] + [len(c)]:
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
858
|
+
text = c[start:end]
|
|
859
|
+
if SECRET_SINK.search(masked[start:end]):
|
|
860
|
+
if SECRET_TOKEN.search(text) and not (relax and _env_prefix_only(text)):
|
|
861
|
+
return SECRET_DENY
|
|
862
|
+
if (carried & {a or b for a, b in SHELL_VAR.findall(text)}
|
|
863
|
+
and _printer_owns_sink(text, masked[start:end])):
|
|
864
|
+
return SECRET_DENY
|
|
865
|
+
carried |= {m.group(1) for m in SHELL_ASSIGN.finditer(text)
|
|
866
|
+
if SECRET_TOKEN.search(m.group(0))}
|
|
812
867
|
start = end
|
|
813
868
|
return None
|
|
814
869
|
|
|
815
870
|
|
|
871
|
+
def _env_prefix_only(text):
|
|
872
|
+
"""True when every token in this simple command's text sits in its leading
|
|
873
|
+
`NAME=value` words (SECRET_PREFIX), no sink runs inside them, the sinks after
|
|
874
|
+
them carry only the program's output (SECRET_OUTPUT: a `git add` or a curl
|
|
875
|
+
trace holds), and the program is on the allowlist (`_secret_runner`, plan
|
|
876
|
+
096). Any doubt reads False: the rule holds."""
|
|
877
|
+
m = SECRET_PREFIX.match(text)
|
|
878
|
+
if not m:
|
|
879
|
+
return False
|
|
880
|
+
rest = text[m.end():]
|
|
881
|
+
return not (SECRET_TOKEN.search(rest) or SECRET_SINK.search(text[:m.end()])
|
|
882
|
+
or SECRET_SINK.search(SECRET_OUTPUT.sub(" ", rest))
|
|
883
|
+
or not _secret_runner(rest))
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _first_command(text):
|
|
887
|
+
"""The words of the first command `text` runs, a `$( )` body skipped."""
|
|
888
|
+
text = text.lstrip(" \t\n;&|")
|
|
889
|
+
return next((w for w, sep in _shell_commands(text) if sep != "$)"), [])
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _secret_runner(text):
|
|
893
|
+
"""True when the first program `text` runs, past the wrappers, is a script
|
|
894
|
+
runner named by itself (SECRET_RUNNER; a path could be any program) given a
|
|
895
|
+
script file (SECRET_SCRIPT), with no `-` or SECRET_INLINE option before it:
|
|
896
|
+
no inline text, stdin, preload, interactive mode or trace."""
|
|
897
|
+
words = _past_wrappers(_first_command(text))
|
|
898
|
+
if not words or not SECRET_RUNNER.fullmatch(words[0]):
|
|
899
|
+
return False
|
|
900
|
+
name = words[0]
|
|
901
|
+
args = words[1:]
|
|
902
|
+
if name == "deno":
|
|
903
|
+
if args[:1] != ["run"]:
|
|
904
|
+
return False
|
|
905
|
+
args = args[1:]
|
|
906
|
+
inline = SECRET_INLINE["python" if name.startswith("python")
|
|
907
|
+
else "js" if name in ("node", "bun", "deno") else "sh"]
|
|
908
|
+
i = 0
|
|
909
|
+
while i < len(args):
|
|
910
|
+
arg = args[i]
|
|
911
|
+
if arg == "-" or inline.match(arg):
|
|
912
|
+
return False
|
|
913
|
+
if not arg.startswith(("-", "+")):
|
|
914
|
+
return bool(SECRET_SCRIPT.fullmatch(arg))
|
|
915
|
+
i += 2 if arg in INTERPRETER_VALUE_OPTS else 1
|
|
916
|
+
return False
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _printer_owns_sink(text, masked):
|
|
920
|
+
"""True when a pipeline stage of this simple command that owns a file sink
|
|
921
|
+
- a `>` to a file other than /dev/null, or a pipe into `tee` - is a printer:
|
|
922
|
+
SECRET_PRINTERS, an interpreter running its own text, or a program the shell
|
|
923
|
+
names at run time. `echo "$T" | docker login --password-stdin > log` hands
|
|
924
|
+
the key to docker's stdin, and docker's output is what lands."""
|
|
925
|
+
cuts = [m.start() for m in re.finditer(r"(?<!>)\|", masked)]
|
|
926
|
+
stages = list(zip([0] + [k + 1 for k in cuts], cuts + [len(text)]))
|
|
927
|
+
for k, (a, b) in enumerate(stages):
|
|
928
|
+
owns = any(text[a + m.end():b].split()[:1] != ["/dev/null"]
|
|
929
|
+
for m in re.finditer(r">>?(?![&=])", masked[a:b]))
|
|
930
|
+
if k + 1 < len(stages) and re.match(r"\s*tee\b", masked[stages[k + 1][0]:]):
|
|
931
|
+
owns = True
|
|
932
|
+
if owns and _secret_printer(text[a:b]):
|
|
933
|
+
return True
|
|
934
|
+
return False
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _secret_printer(text):
|
|
938
|
+
"""True when the first program `text` runs prints a carried name."""
|
|
939
|
+
program = _program(_first_command(text))[0]
|
|
940
|
+
return (program in SECRET_PRINTERS or program in INTERPRETERS
|
|
941
|
+
or bool(re.fullmatch(r"python[\d.]+", program)
|
|
942
|
+
or UNEXPANDED.search(program)))
|
|
943
|
+
|
|
944
|
+
|
|
816
945
|
def _secret_add(command):
|
|
817
946
|
"""The credential file a `git add` in this command names, or None."""
|
|
818
947
|
if SECRET_FILE is None or "add" not in command:
|
|
@@ -1064,12 +1193,13 @@ def known_tail(path, top, command=""):
|
|
|
1064
1193
|
PLAN_PHASE_LINE = re.compile(r"(?m)^\s*\+?\s*phase\s*:\s*\S")
|
|
1065
1194
|
|
|
1066
1195
|
|
|
1067
|
-
def workspace_free(rel, inp):
|
|
1196
|
+
def workspace_free(rel, inp, path=""):
|
|
1068
1197
|
"""True when `rel` is tezgah's own workspace the plan does not scope: every
|
|
1069
1198
|
`.tezgah/` path but the slice journal (`tezgah-task slice` writes it) and a
|
|
1070
1199
|
plan file that could make itself the active task - a shell write into
|
|
1071
|
-
`.tezgah/plans/open/` (its text is unknown) or a write tool
|
|
1072
|
-
carries a `phase:` line
|
|
1200
|
+
`.tezgah/plans/open/` (its text is unknown) or a write tool whose plan text,
|
|
1201
|
+
once written, carries a `phase:` line (`_text_after`, read from `path`).
|
|
1202
|
+
plan-add's own plan file and README stay free.
|
|
1073
1203
|
Compared casefolded: on a case-insensitive disk `.tezgah/PLANS/` is the
|
|
1074
1204
|
plans directory (review of plan 083, N2 and N5)."""
|
|
1075
1205
|
low = (rel or "").casefold()
|
|
@@ -1078,11 +1208,50 @@ def workspace_free(rel, inp):
|
|
|
1078
1208
|
if low.startswith(".tezgah/plans/open/"):
|
|
1079
1209
|
if not isinstance(inp, dict) or inp.get("command") or inp.get("cmd"):
|
|
1080
1210
|
return False
|
|
1081
|
-
return not
|
|
1082
|
-
for k in EDIT_TEXT)
|
|
1211
|
+
return not PLAN_PHASE_LINE.search(_text_after(inp, path))
|
|
1083
1212
|
return True
|
|
1084
1213
|
|
|
1085
1214
|
|
|
1215
|
+
_OLD_TEXT = ("old_string", "oldString", "old_str")
|
|
1216
|
+
_NEW_TEXT = ("new_string", "newString", "new_str")
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def _text_after(inp, path):
|
|
1220
|
+
"""The file's text once this write lands, as far as the call says: a Write's
|
|
1221
|
+
content, the current text with an Edit's (or a MultiEdit's) replacements
|
|
1222
|
+
made, and for any other shape (a patch, a host's own edit format) the
|
|
1223
|
+
current text beside every new string - the refusing direction. Judging the
|
|
1224
|
+
new string alone let two edits each harmless on its own (`xphase:`, then
|
|
1225
|
+
`xphase` -> `phase`) give an unphased plan a phase."""
|
|
1226
|
+
for key in ("content", "file_text"):
|
|
1227
|
+
if isinstance(inp.get(key), str):
|
|
1228
|
+
return inp[key]
|
|
1229
|
+
try:
|
|
1230
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
1231
|
+
text = fh.read()
|
|
1232
|
+
except (OSError, TypeError):
|
|
1233
|
+
text = ""
|
|
1234
|
+
edits = inp.get("edits") if isinstance(inp.get("edits"), list) else [inp]
|
|
1235
|
+
pairs = [(next((e[k] for k in _OLD_TEXT if isinstance(e.get(k), str)), None),
|
|
1236
|
+
next((e[k] for k in _NEW_TEXT if isinstance(e.get(k), str)), None), e)
|
|
1237
|
+
for e in edits if isinstance(e, dict)]
|
|
1238
|
+
if pairs and all(old is not None and new is not None for old, new, _ in pairs):
|
|
1239
|
+
for old, new, e in pairs:
|
|
1240
|
+
text = text.replace(old, new) if e.get("replace_all") else text.replace(old, new, 1)
|
|
1241
|
+
return text
|
|
1242
|
+
return "\n".join([text] + [inp[k] for k in EDIT_TEXT + ("input",)
|
|
1243
|
+
if isinstance(inp.get(k), str)])
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _scratch(path, cwd):
|
|
1247
|
+
"""`scratch_target`, unless a git checkout holds `path`: a checkout under the
|
|
1248
|
+
temp dir (CI's /tmp, a worktree beside one) is a checkout, and its files meet
|
|
1249
|
+
the race, lease and task rules like any other's. A plain temp file stays
|
|
1250
|
+
scratch (plan 099)."""
|
|
1251
|
+
return scratch_target(path, cwd) and not _toplevel(
|
|
1252
|
+
os.path.realpath(os.path.join(cwd or os.getcwd(), path)))
|
|
1253
|
+
|
|
1254
|
+
|
|
1086
1255
|
def race_reason(inp, session_id, cwd=None):
|
|
1087
1256
|
"""A deny reason when another session wrote one of this call's files inside
|
|
1088
1257
|
RACE_WINDOW_MIN, else None.
|
|
@@ -1103,7 +1272,7 @@ def race_reason(inp, session_id, cwd=None):
|
|
|
1103
1272
|
# real target two sessions both write, and stays guarded.
|
|
1104
1273
|
# a shell target whose `$VAR` has no known value names no file the
|
|
1105
1274
|
# ledger could hold either (unnamed_target)
|
|
1106
|
-
if (URI_CHANNEL.match(path) or
|
|
1275
|
+
if (URI_CHANNEL.match(path) or _scratch(path, cwd)
|
|
1107
1276
|
or (not URI_TARGET.match(path) and unnamed_target(path, inp))):
|
|
1108
1277
|
continue # a temp file or a device is no one's shared work
|
|
1109
1278
|
others = [w for w in writers_elsewhere(path, session_id, RACE_WINDOW_MIN,
|
|
@@ -1250,7 +1419,7 @@ def lease_reason(inp, session_id, cwd=None, agent=None):
|
|
|
1250
1419
|
if inp.get("command") or inp.get("cmd"):
|
|
1251
1420
|
here = _shell_dir(inp, here)
|
|
1252
1421
|
for path in write_paths(inp):
|
|
1253
|
-
if (URI_CHANNEL.match(path) or
|
|
1422
|
+
if (URI_CHANNEL.match(path) or _scratch(path, here)
|
|
1254
1423
|
or unnamed_target(path, inp)):
|
|
1255
1424
|
continue
|
|
1256
1425
|
real = os.path.realpath(os.path.join(here, os.path.expanduser(path)))
|
|
@@ -1428,12 +1597,138 @@ def _task(cwd, base, task):
|
|
|
1428
1597
|
return task
|
|
1429
1598
|
if tezgah_task is None:
|
|
1430
1599
|
return None
|
|
1431
|
-
return tezgah_task.active(cwd, base)
|
|
1600
|
+
return tezgah_task.active(cwd, base, held=True)
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def _resolved(spelled, top, command):
|
|
1604
|
+
"""A write target as the task rules read it: a shell target behind a
|
|
1605
|
+
variable the same line set to the checkout is that checkout path
|
|
1606
|
+
(`known_tail`); any other target is the call's own spelling."""
|
|
1607
|
+
return (known_tail(spelled, top, command) or spelled) if command else spelled
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
# worktree_task per (target, cwd, base) for one decision: the phase, scope and
|
|
1611
|
+
# checkpoint rules each ask it of the same targets, and each answer costs a
|
|
1612
|
+
# realpath and a read of the worktree's pointer files. `decision` clears it.
|
|
1613
|
+
_WORKTREE_TASKS = {}
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
def _worktree_task(path, cwd, base):
|
|
1617
|
+
key = (path, cwd, base)
|
|
1618
|
+
if key not in _WORKTREE_TASKS:
|
|
1619
|
+
_WORKTREE_TASKS[key] = tezgah_task.worktree_task(path, cwd, base)
|
|
1620
|
+
return _WORKTREE_TASKS[key]
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
# Files under no repository that the task rule still holds: an agent definition,
|
|
1624
|
+
# an instruction file or a shell rc steers every later session, so it is never
|
|
1625
|
+
# the repository-free work `_no_repo` exempts. Globs relative to HOME, matched
|
|
1626
|
+
# without case (a case-insensitive filesystem opens `~/.ZSHRC` as `~/.zshrc`).
|
|
1627
|
+
HOME_HELD = (".claude/agents/**", ".claude/hooks/**", ".claude/commands/**",
|
|
1628
|
+
".claude/skills/**", ".claude/CLAUDE.md", ".omp/agent/**",
|
|
1629
|
+
".codex/AGENTS.md", ".codex/prompts/**", ".cursor/**",
|
|
1630
|
+
".config/opencode/**", ".zshrc", ".zshenv", ".zprofile", ".zlogin",
|
|
1631
|
+
".bashrc", ".bash_profile", ".bash_login", ".profile")
|
|
1632
|
+
# A line that makes a link: a path under no checkout may point into one by the
|
|
1633
|
+
# time the write runs (`ln -s <repo>/hooks ~/l && echo x > ~/l/a.py`).
|
|
1634
|
+
LINK_MAKER = re.compile(
|
|
1635
|
+
r"(?<![\w-])(?:ln|link|install)\s|"
|
|
1636
|
+
r"(?<![\w-])cp\s[^;&|\n]*?(?:\s-[A-Za-z]*[sl]|\s--(?:symbolic-)?link\b)")
|
|
1637
|
+
# A line that moves the directory, however spelled (`builtin cd`, `X=1 cd`,
|
|
1638
|
+
# `(cd x; ...)`), or reads it back (`$PWD`, quoted or not): where its targets
|
|
1639
|
+
# land is not read.
|
|
1640
|
+
DIR_MOVE = re.compile(r"(?<![\w-])(?:cd|pushd|popd)\b")
|
|
1641
|
+
PWD_READ = re.compile(r"\$\{?(?:OLD)?PWD\b")
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def _no_repo(path, inp, cwd, command):
|
|
1645
|
+
"""True when the write target `path` is no repository's work, so the task
|
|
1646
|
+
rules leave it to the rules every write meets (plan 094: the user's
|
|
1647
|
+
`~/.config/runpod/key` was refused as outside the repository).
|
|
1648
|
+
|
|
1649
|
+
Every doubt holds it: a line that moves the directory or makes a link, a
|
|
1650
|
+
parent directory that does not exist yet, a file with a second hard link
|
|
1651
|
+
and HOME_HELD. Then a target under a git checkout or a configured root is
|
|
1652
|
+
some repository's; a relative one is read in the call's own directory."""
|
|
1653
|
+
masked = mask(command) if command else ""
|
|
1654
|
+
if (DIR_MOVE.search(masked) or PWD_READ.search(command or "")
|
|
1655
|
+
or LINK_MAKER.search(masked)):
|
|
1656
|
+
return False
|
|
1657
|
+
path = os.path.expanduser(path)
|
|
1658
|
+
if not os.path.isabs(path):
|
|
1659
|
+
path = os.path.join(_shell_dir(inp, cwd) if command else cwd or "", path)
|
|
1660
|
+
path = os.path.normpath(path)
|
|
1661
|
+
real = os.path.realpath(path)
|
|
1662
|
+
if root_for(real) or _toplevel(real) or not os.path.isdir(os.path.dirname(real)):
|
|
1663
|
+
return False
|
|
1664
|
+
try:
|
|
1665
|
+
if os.path.isfile(real) and os.stat(real).st_nlink > 1:
|
|
1666
|
+
return False
|
|
1667
|
+
except OSError:
|
|
1668
|
+
return False
|
|
1669
|
+
home = os.path.expanduser("~")
|
|
1670
|
+
for p, h in ((path, home), (real, os.path.realpath(home))):
|
|
1671
|
+
rel = os.path.relpath(p, h).replace(os.sep, "/").casefold()
|
|
1672
|
+
if not rel.startswith("../") and any(
|
|
1673
|
+
tezgah_task.match(rel, g.casefold()) for g in HOME_HELD):
|
|
1674
|
+
return False
|
|
1675
|
+
return True
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
def _write_targets(inp, command):
|
|
1679
|
+
"""The targets the task rules read: for a shell line that writes, every
|
|
1680
|
+
redirect and `tee` target (`shell_targets`, expanded as `write_paths`
|
|
1681
|
+
expands its one), else write_paths."""
|
|
1682
|
+
if command and SHELL_WRITE.search(mask(command)):
|
|
1683
|
+
return [os.path.expanduser(_expand(t, command)) for t in shell_targets(command)]
|
|
1684
|
+
return write_paths(inp)
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
def _owners(inp, cwd, base, task):
|
|
1688
|
+
"""The records this call's write targets are held to, each once: plan NNN's
|
|
1689
|
+
for a target in a sibling worktree on a `plan/NNN-...` branch
|
|
1690
|
+
(`tezgah_task.worktree_task`), none for a target that is no repository's
|
|
1691
|
+
(`_no_repo`), the active task for every other target, and the active
|
|
1692
|
+
task alone when the call names no target.
|
|
1693
|
+
|
|
1694
|
+
A shell line is read target by target (`shell_targets`: every redirect and
|
|
1695
|
+
`tee` target). The cwd's task is an owner too when the line writes where no
|
|
1696
|
+
target was read: a positional writer (`sed -i`, `cp`, `mv`, `truncate`,
|
|
1697
|
+
`dd`, `patch`, `git apply`, a `tee` with other than one operand) or more
|
|
1698
|
+
write shapes than targets read.
|
|
1699
|
+
|
|
1700
|
+
A held reading (`tezgah_task.active` with `held`: every phased plan is held
|
|
1701
|
+
by its worktree, and this checkout is none of them) owns no target: a
|
|
1702
|
+
held plan's scope is its worktree's, so a target outside the plan worktrees
|
|
1703
|
+
is held to no plan."""
|
|
1704
|
+
scope = None if task.get("held") else task
|
|
1705
|
+
top = tezgah_task.repo_root(cwd, base) if cwd else None
|
|
1706
|
+
command = str(inp.get("command") or inp.get("cmd") or "") if isinstance(inp, dict) else ""
|
|
1707
|
+
paths = _write_targets(inp, command)
|
|
1708
|
+
out = []
|
|
1709
|
+
for spelled in paths:
|
|
1710
|
+
path = _resolved(spelled, top, command)
|
|
1711
|
+
if _no_repo(path, inp, cwd, command):
|
|
1712
|
+
continue
|
|
1713
|
+
own = _worktree_task(path, cwd, base) or scope
|
|
1714
|
+
if own and own not in out:
|
|
1715
|
+
out.append(own)
|
|
1716
|
+
if command and scope and scope not in out:
|
|
1717
|
+
masked = mask(command)
|
|
1718
|
+
shapes = SHELL_WRITE.findall(masked)
|
|
1719
|
+
positional = any(not s.startswith(">") and "tee" not in s for s in shapes) or any(
|
|
1720
|
+
len([t for t in re.split(r"[;&|<>\n]", masked[m.end():], maxsplit=1)[0].split()
|
|
1721
|
+
if not t.startswith("-")]) != 1
|
|
1722
|
+
for m in re.finditer(r"\|\s*tee\b", masked))
|
|
1723
|
+
if positional or len(shapes) > len(paths):
|
|
1724
|
+
out.append(scope)
|
|
1725
|
+
return out if paths or not scope else [scope]
|
|
1432
1726
|
|
|
1433
1727
|
|
|
1434
1728
|
def task_shell_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
1435
|
-
"""A deny reason when
|
|
1436
|
-
|
|
1729
|
+
"""A deny reason when this shell command writes a file and the phase of the
|
|
1730
|
+
record its target is held to (`_owners`: a plan worktree's own plan, else
|
|
1731
|
+
the active task) only reads, else None.
|
|
1437
1732
|
|
|
1438
1733
|
The route to the hole the phase rule closes, and the one E7b measured: with
|
|
1439
1734
|
the write tools refused, the armed arm wrote the target with a heredoc
|
|
@@ -1445,15 +1740,18 @@ def task_shell_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
|
1445
1740
|
write tool's path, while a positional target is not read at all (see
|
|
1446
1741
|
write_paths), and the same fail-open holds: no record, no requirement."""
|
|
1447
1742
|
task = _task(cwd, base, task)
|
|
1448
|
-
if not task or
|
|
1449
|
-
return None
|
|
1450
|
-
if not SHELL_WRITE.search(mask(str(inp.get("command") or ""))):
|
|
1743
|
+
if not task or not SHELL_WRITE.search(mask(str(inp.get("command") or ""))):
|
|
1451
1744
|
return None
|
|
1452
|
-
|
|
1745
|
+
for own in _owners(inp, cwd, base, task):
|
|
1746
|
+
if own["phase"] not in tezgah_task.WRITE_PHASES:
|
|
1747
|
+
return TASK_SHELL_DENY % (own["id"], own["phase"])
|
|
1748
|
+
return None
|
|
1453
1749
|
|
|
1454
1750
|
|
|
1455
1751
|
def task_record_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
1456
|
-
"""A deny reason when this write's target IS the active task's record
|
|
1752
|
+
"""A deny reason when this write's target IS the active task's record, or
|
|
1753
|
+
any other open plan carrying a phase (a plan running beside it in its own
|
|
1754
|
+
worktree, `tezgah_task.branch_task`).
|
|
1457
1755
|
|
|
1458
1756
|
The record is a file in the repo, so an agent that may write files could
|
|
1459
1757
|
retype the phase instead of running the CLI, and skip the Acceptance, spike
|
|
@@ -1462,10 +1760,15 @@ def task_record_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
|
1462
1760
|
task = _task(cwd, base, task)
|
|
1463
1761
|
if not task:
|
|
1464
1762
|
return None
|
|
1465
|
-
|
|
1466
|
-
|
|
1763
|
+
# read against the main checkout, which keeps the records, so a session in
|
|
1764
|
+
# a plan worktree meets the same rule; without case, since a
|
|
1765
|
+
# case-insensitive filesystem opens `.TEZGAH/...` as the record
|
|
1766
|
+
record = (tezgah_task.main_relative(task["path"], cwd, base) or "").casefold()
|
|
1767
|
+
root = tezgah_task.repo_root(cwd, base)
|
|
1768
|
+
main = linked_main(root) or root
|
|
1467
1769
|
for path in write_paths(inp):
|
|
1468
|
-
|
|
1770
|
+
rel = tezgah_task.main_relative(path, cwd, base)
|
|
1771
|
+
if rel and (rel.casefold() == record or tezgah_task.phased_record(main, rel)):
|
|
1469
1772
|
return TASK_RECORD_DENY % path
|
|
1470
1773
|
return None
|
|
1471
1774
|
|
|
@@ -1481,49 +1784,69 @@ def task_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
|
1481
1784
|
hunk like every other write rule's here. A path that resolves outside the
|
|
1482
1785
|
repo root is refused by `relative` before any pattern is tried - a `**`
|
|
1483
1786
|
allowlist must not reach out of the repo - unless it is the session's own
|
|
1484
|
-
scratch (`scratch_target`: a temp file, a device), which is no task's work
|
|
1485
|
-
|
|
1787
|
+
scratch (`scratch_target`: a temp file, a device), which is no task's work,
|
|
1788
|
+
or is no repository's at all (`_no_repo`), which is no plan's;
|
|
1789
|
+
build output inside the repo is a repo path and stays held, and so is a path
|
|
1790
|
+
in the repo or its worktrees when the checkout lives under the temp dir
|
|
1791
|
+
(plan 099: CI's /tmp). The refusal
|
|
1486
1792
|
names the file as the call spelled it, because the agent has to recognize
|
|
1487
|
-
its own argument.
|
|
1793
|
+
its own argument.
|
|
1794
|
+
|
|
1795
|
+
A target in a sibling worktree on a `plan/NNN-...` branch is held to plan
|
|
1796
|
+
NNN's record when that plan carries a phase (`tezgah_task.worktree_task`):
|
|
1797
|
+
two plans run at once, one per worktree, and neither's scope reaches into
|
|
1798
|
+
the other's checkout. Every other target keeps the active task."""
|
|
1488
1799
|
task = _task(cwd, base, task)
|
|
1489
1800
|
if not task:
|
|
1490
1801
|
return None
|
|
1802
|
+
# a held reading scopes nothing outside the plan worktrees (`_owners`)
|
|
1803
|
+
scope = None if task.get("held") else task
|
|
1491
1804
|
# The targets the plan scopes: not a URI or a shell target with no known
|
|
1492
1805
|
# value (unnamed_target), and not tezgah's own gitignored workspace under
|
|
1493
1806
|
# `.tezgah/` (research lines, lessons, briefs) - the active record there is
|
|
1494
1807
|
# task_record_reason's, refused before this rule runs. A call whose every
|
|
1495
1808
|
# target is one of those writes no project file, so the phase does not
|
|
1496
1809
|
# refuse it either; a call with no target at all still meets the phase.
|
|
1497
|
-
|
|
1810
|
+
# A shell line is read target by target: each redirect and `tee` target
|
|
1811
|
+
# meets the allowlist of the record it lands under.
|
|
1812
|
+
command = str(inp.get("command") or inp.get("cmd") or "") if isinstance(inp, dict) else ""
|
|
1813
|
+
paths = _write_targets(inp, command)
|
|
1498
1814
|
project = []
|
|
1499
1815
|
top = tezgah_task.repo_root(cwd, base) if cwd else None
|
|
1500
|
-
command = str(inp.get("command") or inp.get("cmd") or "") if isinstance(inp, dict) else ""
|
|
1501
1816
|
for spelled in paths:
|
|
1502
1817
|
if unnamed_target(spelled, inp, top):
|
|
1503
1818
|
continue
|
|
1504
|
-
path = (
|
|
1819
|
+
path = _resolved(spelled, top, command)
|
|
1505
1820
|
rel = tezgah_task.relative(path, cwd, base, worktrees=True)
|
|
1506
|
-
if rel is
|
|
1821
|
+
if rel is None and _no_repo(path, inp, cwd, command):
|
|
1822
|
+
continue
|
|
1823
|
+
full = os.path.join(cwd or "", os.path.expanduser(path))
|
|
1824
|
+
# the main checkout's `.tezgah` is every checkout's workspace (plan 097),
|
|
1825
|
+
# so a worktree session meets the exemption the main checkout gets there
|
|
1826
|
+
# (review of plan 097, F1); a phased record stays task_record_reason's
|
|
1827
|
+
ws_rel = rel if rel is not None else tezgah_task.main_relative(path, cwd, base)
|
|
1828
|
+
if ws_rel is not None and workspace_free(ws_rel, inp, full):
|
|
1507
1829
|
continue
|
|
1508
|
-
|
|
1830
|
+
own = (_worktree_task(path, cwd, base) if rel is not None else None) or scope
|
|
1831
|
+
if own:
|
|
1832
|
+
project.append((spelled, path, rel, own))
|
|
1509
1833
|
if paths and not project:
|
|
1510
1834
|
return None
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
if scratch_target(spelled, cwd):
|
|
1835
|
+
for own in [entry[3] for entry in project] or [o for o in (scope,) if o]:
|
|
1836
|
+
if own["phase"] not in tezgah_task.WRITE_PHASES:
|
|
1837
|
+
return TASK_PHASE_DENY % (own["id"], own["phase"],
|
|
1838
|
+
" or ".join(tezgah_task.WRITE_PHASES))
|
|
1839
|
+
for spelled, path, rel, own in project:
|
|
1840
|
+
patterns = own["allowed_paths"]
|
|
1841
|
+
if not patterns or (rel is None and scratch_target(spelled, cwd)):
|
|
1519
1842
|
continue
|
|
1520
1843
|
if rel is None or not any(tezgah_task.match(rel, p) for p in patterns):
|
|
1521
1844
|
# the call's own spelling: the agent has to recognize its argument
|
|
1522
1845
|
if rel is None:
|
|
1523
|
-
return TASK_SCOPE_OUTSIDE % (spelled,
|
|
1846
|
+
return TASK_SCOPE_OUTSIDE % (spelled, own["id"], ", ".join(patterns))
|
|
1524
1847
|
ready = " ".join(["tezgah-task allow"] + [shlex.quote(p) for p in
|
|
1525
1848
|
list(patterns) + [rel]])
|
|
1526
|
-
return TASK_SCOPE_DENY % (spelled,
|
|
1849
|
+
return TASK_SCOPE_DENY % (spelled, own["id"], ", ".join(patterns), ready)
|
|
1527
1850
|
return None
|
|
1528
1851
|
|
|
1529
1852
|
|
|
@@ -1573,9 +1896,11 @@ CHECKPOINT_DENY = (
|
|
|
1573
1896
|
|
|
1574
1897
|
|
|
1575
1898
|
def checkpoint_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
1576
|
-
"""A deny reason when the
|
|
1577
|
-
|
|
1578
|
-
|
|
1899
|
+
"""A deny reason when the record a write target is held to (`_owners`: a
|
|
1900
|
+
plan worktree's own plan, else the active task) is in `implementation`, its
|
|
1901
|
+
plan's `checkpoint:` waits for a commit (`pending <sha>`), that sha is still
|
|
1902
|
+
HEAD of the checkout the plan works in (the plan's worktree, else this
|
|
1903
|
+
root), and that tree still holds uncommitted work - else None.
|
|
1579
1904
|
|
|
1580
1905
|
The recorded sha, not the word `pending`, is the switch: the commit the
|
|
1581
1906
|
refusal names moves HEAD off it, so the refusal clears by itself. The section
|
|
@@ -1592,14 +1917,25 @@ def checkpoint_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
|
1592
1917
|
if command is None and paths and all(URI_TARGET.match(p) for p in paths):
|
|
1593
1918
|
return None # a tool device or a message changes no tree state
|
|
1594
1919
|
task = _task(cwd, base, task)
|
|
1595
|
-
if not task
|
|
1920
|
+
if not task:
|
|
1921
|
+
return None
|
|
1922
|
+
for own in _owners(inp, cwd, base, task):
|
|
1923
|
+
reason = _pending_checkpoint(own, own.get("worktree")
|
|
1924
|
+
or tezgah_task.repo_root(cwd, base))
|
|
1925
|
+
if reason:
|
|
1926
|
+
return reason
|
|
1927
|
+
return None
|
|
1928
|
+
|
|
1929
|
+
|
|
1930
|
+
def _pending_checkpoint(task, root):
|
|
1931
|
+
"""checkpoint_reason for one record against the checkout `root`."""
|
|
1932
|
+
if task["phase"] != "implementation":
|
|
1596
1933
|
return None
|
|
1597
1934
|
sha = tezgah_task.checkpoint_sha(task.get("checkpoint"))
|
|
1598
1935
|
if not sha:
|
|
1599
1936
|
return None
|
|
1600
1937
|
# HEAD read from the ref file, not a fork: this question is asked on every
|
|
1601
1938
|
# write of the phase, and an unreadable HEAD fails open.
|
|
1602
|
-
root = tezgah_task.repo_root(cwd, base)
|
|
1603
1939
|
if _head(root) != sha:
|
|
1604
1940
|
return None
|
|
1605
1941
|
import subprocess # deferred: only a plan still pending its checkpoint pays
|
|
@@ -1610,7 +1946,8 @@ def checkpoint_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
|
1610
1946
|
return None
|
|
1611
1947
|
if listed.returncode != 0 or not listed.stdout.strip():
|
|
1612
1948
|
return None
|
|
1613
|
-
return CHECKPOINT_DENY % (task["id"], tezgah_task.checkpoint_command(
|
|
1949
|
+
return CHECKPOINT_DENY % (task["id"], tezgah_task.checkpoint_command(
|
|
1950
|
+
task["path"], task.get("worktree")))
|
|
1614
1951
|
|
|
1615
1952
|
|
|
1616
1953
|
# --- workspace: per-project tezgah state lives under <repo>/.tezgah only ------
|
|
@@ -1825,8 +2162,12 @@ def shell_target(command):
|
|
|
1825
2162
|
if not SHELL_WRITE.search(masked):
|
|
1826
2163
|
return ""
|
|
1827
2164
|
m = SHELL_TARGET.search(masked)
|
|
1828
|
-
if
|
|
1829
|
-
|
|
2165
|
+
return _target_slice(c, m) if m else ""
|
|
2166
|
+
|
|
2167
|
+
|
|
2168
|
+
def _target_slice(c, m):
|
|
2169
|
+
"""The target one SHELL_TARGET match `m` (over the masked text) names in the
|
|
2170
|
+
raw command `c`."""
|
|
1830
2171
|
start, end = m.span(1) if m.group(1) else m.span(2)
|
|
1831
2172
|
raw = c[start:end]
|
|
1832
2173
|
# `\S+` runs to the next whitespace, so the separator of a chained command
|
|
@@ -1839,6 +2180,16 @@ def shell_target(command):
|
|
|
1839
2180
|
return raw.strip("'\"").rstrip(";|&)")
|
|
1840
2181
|
|
|
1841
2182
|
|
|
2183
|
+
def shell_targets(command):
|
|
2184
|
+
"""Every redirect and `tee` target a shell command's text names, in order,
|
|
2185
|
+
`> /dev/null` aside - the per-target reading the task rules need where
|
|
2186
|
+
`shell_target` gives the first one only."""
|
|
2187
|
+
c = str(command or "")
|
|
2188
|
+
masked = mask(c)
|
|
2189
|
+
return [t for t in (_target_slice(c, m) for m in SHELL_TARGET.finditer(masked))
|
|
2190
|
+
if t and t != "/dev/null"]
|
|
2191
|
+
|
|
2192
|
+
|
|
1842
2193
|
def shell_write_body(command, cwd=None):
|
|
1843
2194
|
"""The `{file_path, content}` a shell command writes into a file, or None when
|
|
1844
2195
|
this command writes no file's content of its own.
|
|
@@ -2175,9 +2526,10 @@ def _head(root):
|
|
|
2175
2526
|
Read from `.git/HEAD` - the ref file, or the packed-refs line beside it -
|
|
2176
2527
|
rather than a `git` fork, the way `_branch` reads the branch: the checkpoint
|
|
2177
2528
|
rule asks this on every write of an `implementation` phase, and an
|
|
2178
|
-
unanswerable question must pass.
|
|
2179
|
-
|
|
2180
|
-
|
|
2529
|
+
unanswerable question must pass. A linked worktree keeps only its HEAD in
|
|
2530
|
+
its own gitdir; its branch refs, loose or packed, live in the common dir its
|
|
2531
|
+
`commondir` file names, so that is where the ref is read (plan 093 reads a
|
|
2532
|
+
plan worktree's checkpoint against that worktree's HEAD)."""
|
|
2181
2533
|
gitdir = _gitdir(root)
|
|
2182
2534
|
if not gitdir:
|
|
2183
2535
|
return ""
|
|
@@ -2190,6 +2542,11 @@ def _head(root):
|
|
|
2190
2542
|
if not ref.startswith("ref: "):
|
|
2191
2543
|
return ref if _SHA.fullmatch(ref) else ""
|
|
2192
2544
|
name = ref[len("ref: "):].strip()
|
|
2545
|
+
try:
|
|
2546
|
+
with open(os.path.join(gitdir, "commondir"), encoding="utf-8") as fh:
|
|
2547
|
+
gitdir = os.path.join(gitdir, fh.readline().strip())
|
|
2548
|
+
except (OSError, UnicodeDecodeError):
|
|
2549
|
+
pass # a main checkout's gitdir is its own common dir
|
|
2193
2550
|
try:
|
|
2194
2551
|
with open(os.path.join(gitdir, name), encoding="utf-8",
|
|
2195
2552
|
errors="replace") as fh:
|
|
@@ -2740,11 +3097,9 @@ WRAPPER_VALUE_OPTS = {"sudo": ("-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "
|
|
|
2740
3097
|
INTERPRETER_VALUE_OPTS = ("-X", "-W", "-o", "+o", "-O")
|
|
2741
3098
|
|
|
2742
3099
|
|
|
2743
|
-
def
|
|
2744
|
-
"""
|
|
2745
|
-
|
|
2746
|
-
by basename, with their options) and an interpreter's options before the
|
|
2747
|
-
script it runs."""
|
|
3100
|
+
def _past_wrappers(words):
|
|
3101
|
+
"""`words` past compound keywords, env assignments and the wrappers
|
|
3102
|
+
(WRAPPER_VALUE_OPTS, by basename, with their options)."""
|
|
2748
3103
|
i = 0
|
|
2749
3104
|
while i < len(words):
|
|
2750
3105
|
word = words[i]
|
|
@@ -2759,7 +3114,14 @@ def _program(words):
|
|
|
2759
3114
|
i += 2 if words[i] in WRAPPER_VALUE_OPTS[wrapper] else 1
|
|
2760
3115
|
if wrapper == "timeout" and i < len(words):
|
|
2761
3116
|
i += 1 # the duration
|
|
2762
|
-
|
|
3117
|
+
return words[i:]
|
|
3118
|
+
|
|
3119
|
+
|
|
3120
|
+
def _program(words):
|
|
3121
|
+
"""(program basename without `.py`, its arguments) for one simple command,
|
|
3122
|
+
past compound keywords, env assignments, the wrappers (`_past_wrappers`)
|
|
3123
|
+
and an interpreter's options before the script it runs."""
|
|
3124
|
+
words = _past_wrappers(words)
|
|
2763
3125
|
if words and words[0] == "[":
|
|
2764
3126
|
# the test command: its operands are not a program, and the command
|
|
2765
3127
|
# after the `]` is (`if [ -d x ]; then touch y; fi`)
|
|
@@ -3126,9 +3488,9 @@ def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
|
|
|
3126
3488
|
# one. A shell command that writes a file through a redirect or `tee` is a
|
|
3127
3489
|
# write here too: the rules below read its target from `write_paths`, the
|
|
3128
3490
|
# reader `capture` and the PostToolUse row already share - unless all it
|
|
3129
|
-
# writes is its own scratch (`
|
|
3491
|
+
# writes is its own scratch (`_scratch`), such as a `> /tmp/check.log`.
|
|
3130
3492
|
writes = t in WRITE_TOOLS or (t in BASH_TOOLS and any(
|
|
3131
|
-
not
|
|
3493
|
+
not _scratch(p, cwd) for p in write_paths(inp)))
|
|
3132
3494
|
if writes and RACE_REFUSE:
|
|
3133
3495
|
reason = race_reason(inp, session_id, cwd)
|
|
3134
3496
|
if reason:
|
|
@@ -3151,8 +3513,12 @@ def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
|
|
|
3151
3513
|
# `.tezgah/plans/open/` and parses a plan, and this path is every write of
|
|
3152
3514
|
# every session. Reads and non-write tools pay nothing.
|
|
3153
3515
|
task = _UNRESOLVED
|
|
3516
|
+
_WORKTREE_TASKS.clear()
|
|
3154
3517
|
if tezgah_task is not None and t in WRITE_TOOLS + BASH_TOOLS:
|
|
3155
|
-
|
|
3518
|
+
# held: with every phased plan held by its worktree the record rule
|
|
3519
|
+
# still refuses a direct write to it, and the scope rules hold a
|
|
3520
|
+
# target outside the plan worktrees to none (`_owners`)
|
|
3521
|
+
task = tezgah_task.active(cwd, base, held=True)
|
|
3156
3522
|
if writes:
|
|
3157
3523
|
reason = task_record_reason(inp, cwd, base, task)
|
|
3158
3524
|
if reason:
|