@r1z4x/tezgah 1.7.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, _turn_start,
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 `git add` that names a
799
- credential file is refused whatever its text holds (`_secret_add`)."""
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
- start = 0
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
- if (SECRET_TOKEN.search(c[start:end])
810
- and SECRET_SINK.search(masked[start:end])):
811
- return SECRET_DENY
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's text that
1072
- carries a `phase:` line. plan-add's own plan file and README stay free.
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 any(isinstance(inp.get(k), str) and PLAN_PHASE_LINE.search(inp[k])
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 scratch_target(path, cwd)
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 scratch_target(path, here)
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 the active task's phase only reads and this shell
1436
- command writes a file, else None.
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 task["phase"] in tezgah_task.WRITE_PHASES:
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
- return TASK_SHELL_DENY % (task["id"], task["phase"])
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
- record = os.path.relpath(task["path"],
1466
- tezgah_task.repo_root(cwd, base)).replace(os.sep, "/")
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
- if tezgah_task.relative(path, cwd, base) == record:
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
- build output inside the repo is a repo path and stays held. The refusal
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
- paths = write_paths(inp)
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 = (known_tail(spelled, top, command) or spelled) if command else spelled
1819
+ path = _resolved(spelled, top, command)
1505
1820
  rel = tezgah_task.relative(path, cwd, base, worktrees=True)
1506
- if rel is not None and workspace_free(rel, inp):
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
- project.append((spelled, path, rel))
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
- if task["phase"] not in tezgah_task.WRITE_PHASES:
1512
- return TASK_PHASE_DENY % (task["id"], task["phase"],
1513
- " or ".join(tezgah_task.WRITE_PHASES))
1514
- patterns = task["allowed_paths"]
1515
- if not patterns:
1516
- return None
1517
- for spelled, path, rel in project:
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, task["id"], ", ".join(patterns))
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, task["id"], ", ".join(patterns), ready)
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 active task is in `implementation`, its plan's
1577
- `checkpoint:` waits for a commit (`pending <sha>`), that sha is still HEAD,
1578
- and the tree still holds uncommitted work - else None.
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 or task["phase"] != "implementation":
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(task["path"]))
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 not m:
1829
- return ""
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. ponytail: a worktree's packed refs live in
2179
- the common dir, not in the worktree gitdir this reads, so a repacked ref in a
2180
- linked worktree falls back to "" - the fail-open direction."""
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 _program(words):
2744
- """(program basename without `.py`, its arguments) for one simple command,
2745
- past compound keywords, env assignments, the wrappers (WRAPPER_VALUE_OPTS,
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
- words = words[i:]
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 (`scratch_target`), such as a `> /tmp/check.log`.
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 scratch_target(p, cwd) for p in write_paths(inp)))
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
- task = tezgah_task.active(cwd, base)
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: