@r1z4x/tezgah 1.6.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +140 -1
  2. package/MANIFEST +7 -0
  3. package/README.md +6 -3
  4. package/bin/tezgah-decisions +6 -14
  5. package/bin/tezgah-design +21 -5
  6. package/bin/tezgah-docs +18 -16
  7. package/bin/tezgah-doctor +152 -0
  8. package/bin/tezgah-lessons +4 -1
  9. package/bin/tezgah-research +249 -17
  10. package/bin/tezgah-route +70 -8
  11. package/bin/tezgah-setup +26 -13
  12. package/bin/tezgah-task +877 -60
  13. package/bin/tezgah-taste +56 -15
  14. package/docs/architecture.md +28 -11
  15. package/docs/clarity-baseline.json +2 -2
  16. package/docs/contract.md +108 -24
  17. package/docs/environment.md +3 -1
  18. package/docs/evidence.md +209 -28
  19. package/docs/feature-audit.md +1 -1
  20. package/docs/gate.md +78 -23
  21. package/docs/glossary.md +4 -4
  22. package/docs/hosts.md +8 -8
  23. package/docs/index.json +11 -5
  24. package/docs/judge.md +9 -3
  25. package/docs/models.md +75 -14
  26. package/docs/operations.md +133 -3
  27. package/docs/research.md +349 -15
  28. package/docs/skills.md +1 -1
  29. package/docs/status-line.md +5 -5
  30. package/docs/testing.md +20 -13
  31. package/docs/vision.md +12 -4
  32. package/hooks/hooks.json +2 -2
  33. package/hooks/lang/en.json +323 -0
  34. package/hooks/lang/tr.json +344 -0
  35. package/hooks/projects-auto-init.py +29 -1
  36. package/hooks/projects-posttooluse.py +24 -5
  37. package/hooks/projects-pretooluse.py +9 -4
  38. package/hooks/projects-stop.py +28 -16
  39. package/hooks/tezgah_agents.py +53 -17
  40. package/hooks/tezgah_clarity.py +7 -3
  41. package/hooks/tezgah_context.py +489 -438
  42. package/hooks/tezgah_gate.py +996 -80
  43. package/hooks/tezgah_integrity.py +1547 -197
  44. package/hooks/tezgah_models.py +427 -45
  45. package/hooks/tezgah_paths.py +147 -16
  46. package/hooks/tezgah_policy.py +261 -129
  47. package/hooks/tezgah_rank.py +29 -27
  48. package/hooks/tezgah_replay.py +200 -30
  49. package/hooks/tezgah_research.py +1303 -14
  50. package/hooks/tezgah_stopspec.py +7 -0
  51. package/hooks/tezgah_store.py +74 -27
  52. package/hooks/tezgah_task.py +1201 -40
  53. package/hooks/tezgah_taste.py +8 -4
  54. package/hosts/codex/hook.py +32 -13
  55. package/hosts/cursor/hook.py +32 -14
  56. package/hosts/dsh/hooks.json +2 -2
  57. package/hosts/omp/hook.py +44 -23
  58. package/hosts/omp/tezgah-hook.ts.in +93 -7
  59. package/hosts/opencode/plugins/tezgah.js +273 -6
  60. package/output-styles/tezgah.md +76 -42
  61. package/package.json +1 -1
  62. package/skills/i-have-adhd/EVALS.md +22 -2
  63. package/skills/i-have-adhd/SKILL.md +12 -5
  64. package/skills/plan-add/SKILL.md +27 -11
  65. package/skills/plan-add/render_table.py +8 -3
  66. package/skills/plan-sync/SKILL.md +5 -3
  67. package/skills/ponytail/EVALS.md +21 -2
  68. package/skills/ponytail/SKILL.md +4 -4
  69. package/skills/research/SKILL.md +89 -1
  70. package/skills/tezgah-contract/EVALS.md +52 -7
  71. package/skills/tezgah-contract/SKILL.md +154 -59
@@ -102,14 +102,16 @@ 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,
109
110
  shortcut_edit, turn_rows, verify_command,
110
111
  write_texts)
112
+ from tezgah_integrity import SUBAGENT_TOOLS, TODO_TOOLS, newest_todo, ready_heads
111
113
  from tezgah_paths import (CACHE, CONFIG_DIR, HOST_DIRS, OFF_DIRS, PLUGIN_ROOT,
112
- REPO_MARKS, SWITCHES, cache_dir, fallback_cache,
114
+ REPO_MARKS, SWITCHES, _toplevel, cache_dir, fallback_cache,
113
115
  linked_main, off, root_for, roots)
114
116
 
115
117
  try: # The ordering rule's two readers (the newest check's state, folded the way
@@ -124,9 +126,9 @@ except ImportError: # pragma: no cover - only on an integrity module without th
124
126
  try: # The race rule reads the write history through tezgah_integrity; that
125
127
  # reader is newer than some checkouts of the module, and a missing name must
126
128
  # cost the rule, never the session (this import runs on every gated call).
127
- from tezgah_integrity import writers_elsewhere
129
+ from tezgah_integrity import spawn_of, writers_elsewhere
128
130
  except ImportError: # pragma: no cover - only on an integrity module without it
129
- writers_elsewhere = None
131
+ spawn_of = writers_elsewhere = None
130
132
 
131
133
  try: # The bytes a write is about to change. The snapshot module is newer than
132
134
  # some checkouts too, and a missing module must cost the snapshot, never the
@@ -266,6 +268,46 @@ SECRET_SINK = re.compile(
266
268
  # log` hands the piped text to tee. The split is read off the masked text, so a
267
269
  # quoted `;` does not split.
268
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"))
269
311
 
270
312
  SECRET_DENY = (
271
313
  "Credential write denied: this command would land a credential in a file "
@@ -794,8 +836,14 @@ def secret_command(command):
794
836
 
795
837
  Reading an env var or running a tool with a key in its env is the normal work
796
838
  this must not touch, so a token only counts next to a write sink, and a sink
797
- only carries the text of its own simple command. A `git add` that names a
798
- 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`)."""
799
847
  c = str(command or "")
800
848
  added = _secret_add(c)
801
849
  if added:
@@ -803,15 +851,97 @@ def secret_command(command):
803
851
  if not c or not SECRET_TOKEN.search(c):
804
852
  return None
805
853
  masked = mask(c)
806
- 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()
807
857
  for end in [m.start() for m in SEGMENT.finditer(masked)] + [len(c)]:
808
- if (SECRET_TOKEN.search(c[start:end])
809
- and SECRET_SINK.search(masked[start:end])):
810
- 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))}
811
867
  start = end
812
868
  return None
813
869
 
814
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
+
815
945
  def _secret_add(command):
816
946
  """The credential file a `git add` in this command names, or None."""
817
947
  if SECRET_FILE is None or "add" not in command:
@@ -896,11 +1026,23 @@ RACE_WINDOW_MIN = 10
896
1026
  # overwrite, so this rule cannot be a soft one.
897
1027
  RACE_REFUSE = True
898
1028
  RACE_DENY = (
899
- "Concurrent write refused: session %s wrote this file within the last %d "
1029
+ "Concurrent write refused: %s wrote this file within the last %d "
900
1030
  "minutes, so the copy of %s in your context may already be stale. Re-read "
901
1031
  "the file and re-apply your change to what is on disk now; if both sessions "
902
1032
  "are editing it, say so and let one of them own the file rather than "
903
1033
  "overwriting the other's work.")
1034
+ # How RACE_DENY names a writer, by its relation to this session
1035
+ # (tezgah_integrity._relation): the bare ledger id sent a model to the wrong
1036
+ # cause - it called an omp fork "a second window of the same session"
1037
+ # (research orchestrator-flow-audit S7, finding 4).
1038
+ RACE_WHO = {
1039
+ "child": "your own subagent %s (ledger %s), not ended in the slice journal",
1040
+ "parent": "your parent session (ledger %s)",
1041
+ "sibling": "sibling subagent %s of your parent (ledger %s)",
1042
+ "fork": "an omp fork of this session (ledger %s)",
1043
+ "forked-from": "the session this one was forked from (ledger %s)",
1044
+ "unrelated": "another, unrelated session %s",
1045
+ }
904
1046
  # The file a write call names, per host dialect, plus the apply_patch headers
905
1047
  # (tezgah_integrity.PATCH_FILE) for the dialect whose paths live in the body.
906
1048
  # The race guard resolves each against the call's cwd to an absolute real path
@@ -911,8 +1053,27 @@ RACE_DENY = (
911
1053
  # tool device, never a file on disk. Deliberately a list, not `scheme://`: a
912
1054
  # file reached through a URI scheme is still a file two sessions can race on.
913
1055
  URI_CHANNEL = re.compile(
914
- r"^(?:agent|xd|local|artifact|proc|skill|mcp|omp|issue|pr|history|ssh|cfg)://",
915
- re.I)
1056
+ r"^(?:agent|xd|local|artifact|proc|skill|mcp|omp|issue|pr|history|ssh|cfg|"
1057
+ r"conflict)://", re.I)
1058
+ # The task rule's wider reading: ANY scheme. Its question is "is this a path in
1059
+ # the checkout", and a URI is not one - `tezgah_task.relative` would read
1060
+ # `xd://mcp__codegraph_explore` as a relative file and refuse a name that exists
1061
+ # nowhere (a user session, 2026-10-11: `xd://` and `conflict://` writes refused
1062
+ # as outside the allowlist).
1063
+ # `file://` is the one scheme that names a checkout file, so it stays guarded,
1064
+ # as the race rule already reads it.
1065
+ URI_TARGET = re.compile(r"^(?!file://)[A-Za-z][A-Za-z0-9+.-]*://", re.I)
1066
+ # A shell target still holding a variable or a substitution once write_paths
1067
+ # expanded what it could: the shell names the file at run time, the gate cannot.
1068
+ UNEXPANDED = re.compile(r"\$[A-Za-z_{(]|`")
1069
+ # One unexpanded piece of a shell target, for `known_tail`.
1070
+ UNEXPANDED_PIECE = re.compile(
1071
+ r"\$\{[^}]*\}|\$\([^)]*\)|\$[A-Za-z_][A-Za-z0-9_]*|`[^`]*`")
1072
+ # `NAME=value` at a word start, and the `$NAME`/`${NAME}` a later word reads.
1073
+ SHELL_ASSIGN = re.compile(
1074
+ r"(?:^|(?<=[\s;&|(]))([A-Za-z_][A-Za-z0-9_]*)="
1075
+ r"((?:'[^']*'|\"[^\"]*\"|[^\s;&|)'\"])*)")
1076
+ SHELL_VAR = re.compile(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))")
916
1077
  WRITE_PATH = ("file_path", "filePath", "path", "notebook_path")
917
1078
  # The write-tool name a shell write's target is handed to `capture` under. capture
918
1079
  # takes a write tool's own path field and no shell tool name, and the row it
@@ -948,8 +1109,147 @@ def write_paths(inp):
948
1109
  paths = [m.group(1) for m in PATCH_FILE.finditer(str(inp.get("patch") or ""))]
949
1110
  if paths:
950
1111
  return paths
951
- target = shell_target(inp.get("command") or inp.get("cmd") or "")
952
- return [os.path.expanduser(os.path.expandvars(target))] if target else []
1112
+ command = str(inp.get("command") or inp.get("cmd") or "")
1113
+ target = shell_target(command)
1114
+ return [os.path.expanduser(_expand(target, command))] if target else []
1115
+
1116
+
1117
+ def _expand(target, command):
1118
+ """`target` with each `$VAR`/`${VAR}` the shell would substitute: an
1119
+ assignment earlier on the same line (`B=/tmp/x; git diff > $B/f`), else the
1120
+ environment. A name with no known value stays as spelled, so
1121
+ `unnamed_target` can tell the gate it does not know the file. ponytail: the
1122
+ assignments are read off the raw text, so `echo "a B=x"` counts as one; that
1123
+ only matters when the target then reads `$B`."""
1124
+ env = dict(os.environ)
1125
+
1126
+ def sub(text):
1127
+ return SHELL_VAR.sub(
1128
+ lambda m: env.get(m.group(1) or m.group(2), m.group(0)), text)
1129
+
1130
+ cut = command.find(target)
1131
+ for m in SHELL_ASSIGN.finditer(command[:cut if cut >= 0 else len(command)]):
1132
+ name, value = m.group(1), m.group(2)
1133
+ if "$(" in value or "`" in value:
1134
+ env.pop(name, None) # a command's output: unknown here
1135
+ elif value[:1] == "'":
1136
+ env[name] = value.strip("'")
1137
+ else:
1138
+ env[name] = sub(value.strip('"'))
1139
+ return sub(target)
1140
+
1141
+
1142
+ def unnamed_target(path, inp, top=None):
1143
+ """True when a write target is no file the task rule can place in the
1144
+ checkout: a URI (a host tool device such as `xd://<tool>`, an agent message,
1145
+ a `conflict://N` resolution) or a shell target whose variable has no known
1146
+ value and that `known_tail` cannot place. Refusing either names a path
1147
+ that does not exist. A write tool's own path is never read for `$`:
1148
+ `Outer$Inner.java` is a real file name."""
1149
+ if URI_TARGET.match(path):
1150
+ return True
1151
+ if not isinstance(inp, dict):
1152
+ return False
1153
+ command = str(inp.get("command") or inp.get("cmd") or "")
1154
+ return bool(command) and bool(UNEXPANDED.search(path)) and not known_tail(
1155
+ path, top, command)
1156
+
1157
+
1158
+ # A value that is the checkout itself: `R=$(git rev-parse --show-toplevel)`,
1159
+ # `R=$(pwd)`, `R=$PWD`.
1160
+ ROOT_VALUE = re.compile(
1161
+ r"^[\"']?(?:\$\(\s*(?:git\s+rev-parse\s+--show-toplevel|pwd)\s*\)"
1162
+ r"|`\s*(?:git\s+rev-parse\s+--show-toplevel|pwd)\s*`|\$\{?PWD\}?)[\"']?$")
1163
+
1164
+
1165
+ def known_tail(path, top, command=""):
1166
+ """The checkout path a target names when it opens on a variable the same
1167
+ command line set to the checkout (`ROOT_VALUE`), or None. `R=$(git
1168
+ rev-parse --show-toplevel); printf x > $R/src/a.py` writes `src/a.py`, so the
1169
+ rule holds that file. Any other unknown variable - a `mktemp -d` directory,
1170
+ a loop variable, one an earlier call exported - leaves the target unnamed:
1171
+ re-rooting `$D/README.md` or `packages/$p/x` at the checkout refused writes
1172
+ that never touched it (review of plan 083, N1).
1173
+ ponytail: a root exported by an earlier call (`export R=$PWD`, then `> $R/x`)
1174
+ is not seen, so that write passes unnamed."""
1175
+ if not top:
1176
+ return None
1177
+ pieces = list(UNEXPANDED_PIECE.finditer(path))
1178
+ if len(pieces) != 1 or pieces[0].start() != 0:
1179
+ return None
1180
+ name = pieces[0].group(0).strip("${}")
1181
+ rest = path[pieces[0].end():].lstrip("/")
1182
+ if not rest or UNEXPANDED.search(rest):
1183
+ return None
1184
+ cut = command.find(path)
1185
+ head = command[:cut if cut >= 0 else len(command)]
1186
+ values = re.findall(r"(?:^|(?<=[\s;&|(]))%s=(\$\([^)]*\)|`[^`]*`|\"[^\"]*\"|\S+)"
1187
+ % re.escape(name), head)
1188
+ return (os.path.join(top, rest)
1189
+ if values and ROOT_VALUE.match(values[-1]) else None)
1190
+
1191
+
1192
+ # The front matter key that makes a plan the active task (tezgah_task.active).
1193
+ PLAN_PHASE_LINE = re.compile(r"(?m)^\s*\+?\s*phase\s*:\s*\S")
1194
+
1195
+
1196
+ def workspace_free(rel, inp, path=""):
1197
+ """True when `rel` is tezgah's own workspace the plan does not scope: every
1198
+ `.tezgah/` path but the slice journal (`tezgah-task slice` writes it) and a
1199
+ plan file that could make itself the active task - a shell write into
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.
1203
+ Compared casefolded: on a case-insensitive disk `.tezgah/PLANS/` is the
1204
+ plans directory (review of plan 083, N2 and N5)."""
1205
+ low = (rel or "").casefold()
1206
+ if not low.startswith(".tezgah/") or low.startswith(".tezgah/slices/"):
1207
+ return False
1208
+ if low.startswith(".tezgah/plans/open/"):
1209
+ if not isinstance(inp, dict) or inp.get("command") or inp.get("cmd"):
1210
+ return False
1211
+ return not PLAN_PHASE_LINE.search(_text_after(inp, path))
1212
+ return True
1213
+
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)))
953
1253
 
954
1254
 
955
1255
  def race_reason(inp, session_id, cwd=None):
@@ -970,15 +1270,239 @@ def race_reason(inp, session_id, cwd=None):
970
1270
  # this guard read the scheme as a relative path. Only the harness's own
971
1271
  # channels are skipped - a `file://`, `s3://` or editor URI can name a
972
1272
  # real target two sessions both write, and stays guarded.
973
- if URI_CHANNEL.match(path) or scratch_target(path, cwd):
1273
+ # a shell target whose `$VAR` has no known value names no file the
1274
+ # ledger could hold either (unnamed_target)
1275
+ if (URI_CHANNEL.match(path) or _scratch(path, cwd)
1276
+ or (not URI_TARGET.match(path) and unnamed_target(path, inp))):
974
1277
  continue # a temp file or a device is no one's shared work
975
- others = writers_elsewhere(path, session_id, RACE_WINDOW_MIN, cwd=cwd)
1278
+ others = [w for w in writers_elsewhere(path, session_id, RACE_WINDOW_MIN,
1279
+ cwd=cwd, lineage=True)
1280
+ if not ended_child(w, session_id, path, cwd)]
976
1281
  if others:
977
- return RACE_DENY % (", ".join(str(s) for s in others[:3]),
1282
+ return RACE_DENY % (", ".join(race_who(*w[:3]) for w in others[:3]),
978
1283
  RACE_WINDOW_MIN, path)
979
1284
  return None
980
1285
 
981
1286
 
1287
+ def race_who(stem, relation, agent):
1288
+ """One writer as RACE_DENY names it (RACE_WHO)."""
1289
+ text = RACE_WHO.get(relation, RACE_WHO["unrelated"])
1290
+ return text % ((agent or "?", stem) if text.count("%s") == 2 else (stem,))
1291
+
1292
+
1293
+ def ended_child(writer, session_id, path, cwd):
1294
+ """True when `writer` (a writers_elsewhere (stem, relation, agent, ts)) is
1295
+ this session's own subagent whose slice record closed after its newest
1296
+ write of this file: a parent taking over a child that stopped on a quota
1297
+ error is no race (research orchestrator-flow-audit S7, finding 3). The
1298
+ record is read after `pending` closes an ended omp child, from cwd's journal
1299
+ and the written file's. No record, one still open, or a write newer than the
1300
+ record's close is a live writer and stays refused. ponytail: the close time
1301
+ is stored to the second, so a write in the second the record closed counts
1302
+ as before the close."""
1303
+ import calendar
1304
+ stem, relation, agent, wrote = writer
1305
+ if relation != "child" or not agent or tezgah_task is None:
1306
+ return False
1307
+ repos = {r for r in (tezgah_task.main_repo(os.path.join(cwd or os.getcwd(), path)),
1308
+ tezgah_task.main_repo(cwd or os.getcwd())) if r}
1309
+ base = re.sub(r"-\d+$", "", agent)
1310
+ recs = []
1311
+ try:
1312
+ for repo in repos:
1313
+ tezgah_task.pending(repo) # closes an omp child whose transcript ended
1314
+ recs += [r for r in tezgah_task.slices(repo) if r.get("parent") == session_id
1315
+ and (r.get("name") or r.get("id")) in (agent, base)]
1316
+ closed = max(calendar.timegm(time.strptime(str(r.get("updated")), "%Y-%m-%dT%H:%M:%SZ"))
1317
+ for r in recs) if recs else None
1318
+ except Exception:
1319
+ return False
1320
+ return (closed is not None and closed >= int(wrote)
1321
+ and all(r.get("status") != "open" for r in recs))
1322
+
1323
+
1324
+ # --- lease: a sibling slice owns this file (plan 086, S6) --------------------
1325
+ # What this closes: the race rule refuses only after the other slice wrote, and
1326
+ # 24 genuine sibling refusals in real transcripts were all in a shared place -
1327
+ # one worktree for 4 or 10 concurrent slices, a predecessor's tree, a shared
1328
+ # plan record (research orchestrator-flow-audit S6, findings 3 and 5). A brief's
1329
+ # `Owns:` line is a lease on the slice record (`tezgah_task.owned_globs`), and a
1330
+ # write by one slice into a path an open sibling of the same parent owns is
1331
+ # refused before any work is lost. The second refusal is the checkout itself:
1332
+ # a slice whose record names a worktree writing into the main checkout (plan
1333
+ # 086 wave 1: two slices did, by relative paths that resolved against the
1334
+ # parent's cwd). Refused, not noted: a notice arrives after the file landed in
1335
+ # the parent's tree, and the fix - the same write under the worktree - costs
1336
+ # one re-issue. `.tezgah/` (the plan record, research data) and scratch stay
1337
+ # free: they live in the main checkout by design.
1338
+ #
1339
+ # The writing session is mapped to its record two ways. omp: its ledger's
1340
+ # `spawned` row (`tezgah_integrity.spawn_of`: parent session and spawned name);
1341
+ # omp writes it for every task child, and the record stays open while the child
1342
+ # runs. Claude: a background child's calls carry the parent's session id and
1343
+ # their own `agent_id`, which the spawn's result named and the record keeps as
1344
+ # `child` (`hooks/projects-posttooluse.py`). Every other case fails open:
1345
+ # Claude's foreground agents, dsh, Cursor and opencode open the record only when
1346
+ # the synchronous spawn returns, so no sibling record is open while the child
1347
+ # writes, and Codex writes no `spawned` row. A slice may write a path its own
1348
+ # `Owns:` names even when a sibling's names it too: the overlap is the parent's
1349
+ # brief, and the race rule still stands between the two.
1350
+ LEASE_DENY = (
1351
+ "Lease: %s is owned by sibling slice %s (owns %s), still open under your "
1352
+ "parent. Leave the file to it: %s, or ask your parent to move the lease; "
1353
+ "once the owner has finished, `tezgah-task slice done %s` frees it. Every "
1354
+ "lease is on `tezgah-task slice board`.")
1355
+ MAIN_CHECKOUT_DENY = (
1356
+ "Lease: %s is in the main checkout %s, but your slice %s works in worktree "
1357
+ "%s. Write the same path under that worktree: an absolute path, or a shell "
1358
+ "whose `cwd` or `cd` is there - a relative path resolves against this "
1359
+ "session's cwd.")
1360
+
1361
+
1362
+ def _channel(rec):
1363
+ """How a sibling reaches the slice `rec` on its host."""
1364
+ if rec.get("host") == "claude" and rec.get("child"):
1365
+ return "message the owner (`SendMessage` to %s)" % rec["child"]
1366
+ if rec.get("host") == "omp":
1367
+ return "message the owner (`write agent://%s`)" % (rec.get("name") or rec["id"])
1368
+ return "ask the owner through your parent"
1369
+
1370
+
1371
+ def _shell_dir(inp, cwd):
1372
+ """The directory a shell call's redirect or `tee` target resolves against:
1373
+ the call's own `cwd`/`workdir`, then each `cd`/`pushd` before the first
1374
+ redirect or `tee` that writes a file, read the way `shell_control` walks
1375
+ the line. A redirect to `/dev/*`, a descriptor (`2>&1`) or `-` writes no
1376
+ file, so `pushd /wt >/dev/null && x > out` resolves `out` under `/wt`."""
1377
+ base = inp.get("cwd") or inp.get("workdir")
1378
+ where, words, pending = _full(base, cwd) if base else cwd, [], False
1379
+ for word, is_op in _shell_words(inp.get("command") or inp.get("cmd") or ""):
1380
+ if pending:
1381
+ pending = False
1382
+ if not is_op:
1383
+ target = _unquote(word)
1384
+ if not (target.startswith(("&", "/dev/")) or target.isdigit()
1385
+ or target == "-"):
1386
+ return where
1387
+ continue
1388
+ if is_op and ">" in word:
1389
+ if words and words[-1].isdigit():
1390
+ words.pop() # the descriptor before `2>`
1391
+ pending = True
1392
+ continue
1393
+ if not is_op:
1394
+ if _unquote(word) == "tee" and not words:
1395
+ return where
1396
+ words.append(_unquote(word))
1397
+ continue
1398
+ while words and words[0] in SHELL_KEYWORDS:
1399
+ words = words[1:]
1400
+ if words and words[0] in ("cd", "pushd"):
1401
+ dest = [a for a in words[1:] if not a.startswith("-")]
1402
+ where = _full(dest[0] if dest else "~", where)
1403
+ words = []
1404
+ return where
1405
+
1406
+
1407
+ def lease_reason(inp, session_id, cwd=None, agent=None):
1408
+ """LEASE_DENY or MAIN_CHECKOUT_DENY for the first of this call's files
1409
+ that meets one, else None. `agent` is Claude's `agent_id`. Fails open: no
1410
+ `spawned` row and no `agent_id`, no open record of that slice, an
1411
+ unreadable journal."""
1412
+ if spawn_of is None or tezgah_task is None or not session_id:
1413
+ return None
1414
+ spawn = spawn_of(session_id) or ((session_id, agent) if agent else None)
1415
+ if not spawn:
1416
+ return None
1417
+ here = cwd or os.getcwd()
1418
+ try:
1419
+ if inp.get("command") or inp.get("cmd"):
1420
+ here = _shell_dir(inp, here)
1421
+ for path in write_paths(inp):
1422
+ if (URI_CHANNEL.match(path) or _scratch(path, here)
1423
+ or unnamed_target(path, inp)):
1424
+ continue
1425
+ real = os.path.realpath(os.path.join(here, os.path.expanduser(path)))
1426
+ repo = tezgah_task.main_repo(real)
1427
+ me = tezgah_task.slice_of(repo, *spawn) if repo else None
1428
+ if not me or me.get("status") != "open":
1429
+ continue
1430
+ tree = me.get("declared") or ""
1431
+ top = linked_main(real) and tezgah_task._sibling_worktree(real, repo)
1432
+ rel = os.path.relpath(real, top or repo).replace(os.sep, "/")
1433
+ if rel.startswith((".tezgah/", "../")) or rel == ".tezgah":
1434
+ continue
1435
+ if not top and tree and tree != repo and os.path.isdir(tree):
1436
+ return MAIN_CHECKOUT_DENY % (rel, repo, me["id"], tree)
1437
+ if any(tezgah_task.match(rel, g) for g in me.get("owns") or ()):
1438
+ continue
1439
+ owner = tezgah_task.owner_of(repo, rel, spawn[0], me["id"])
1440
+ if owner:
1441
+ return LEASE_DENY % (rel, owner.get("name") or owner["id"],
1442
+ ", ".join(owner["owns"][:3]), _channel(owner),
1443
+ owner["id"])
1444
+ except Exception:
1445
+ return None
1446
+ return None
1447
+
1448
+
1449
+ def spawn_advice(tool, inp, session_id, cwd=None):
1450
+ """The shared-worktree warning for a spawn call (`tezgah_task.worktree_clash`),
1451
+ else None. A warning, never a deny: a fan-out of read-only reviewers on one
1452
+ checkout is fine, and the agent name is how this reads which ones are. Off
1453
+ under `orchestrate-off`, whose rule text it backs."""
1454
+ if (str(tool or "").lower() not in SUBAGENT_TOOLS or tezgah_task is None
1455
+ or not cwd or off("orchestrate-off")):
1456
+ return None
1457
+ try:
1458
+ root = tezgah_task.main_repo(cwd)
1459
+ return tezgah_task.worktree_clash(root, tool, inp, session_id or "") if root else None
1460
+ except Exception:
1461
+ return None
1462
+
1463
+
1464
+ # The cross-project write notice: a session works under the rules of the
1465
+ # repository its cwd is in, so a write into another one is under none of that
1466
+ # repository's plans, lessons or slice journal (research orchestrator-flow-audit
1467
+ # S7, finding 7). A notice, never a refusal: the write may be the user's ask.
1468
+ CROSS_PROJECT = (
1469
+ "tezgah: this write is in %s, not in this session's repository (%s). That "
1470
+ "repository's plans, lessons and slice journal do not apply to this session. "
1471
+ "Work on that project from a session opened there.")
1472
+
1473
+
1474
+ def _main_repo(path):
1475
+ """The main checkout of the repository holding `path`, else None
1476
+ (`tezgah_task.main_repo`: a repository's own `.tezgah` is that repository)."""
1477
+ return tezgah_task.main_repo(path) if tezgah_task is not None else None
1478
+
1479
+
1480
+ def cross_project_note(session_id, tool, inp, cwd):
1481
+ """CROSS_PROJECT when this write call's file lies in another repository than
1482
+ cwd's (a worktree counts as its main checkout), once per session per target
1483
+ repository (a `nudged` mark), else None. Every host's post-tool channel
1484
+ carries it beside its other notices. Total: any failure is no notice."""
1485
+ try:
1486
+ name = str(tool or "").lower()
1487
+ if (name not in WRITE_TOOLS and name not in BASH_TOOLS) or not cwd \
1488
+ or not isinstance(inp, dict) or inp.get("command") == "view":
1489
+ return None
1490
+ here = _main_repo(cwd)
1491
+ for path in write_paths(inp):
1492
+ if URI_CHANNEL.match(path) or scratch_target(path, cwd):
1493
+ continue
1494
+ there = _main_repo(os.path.join(cwd, path))
1495
+ if not there or there == here:
1496
+ continue
1497
+ key = "cross:" + hashlib.sha1(("%s\0%s" % (session_id, there)).encode(
1498
+ "utf-8", "replace")).hexdigest()[:16]
1499
+ return CROSS_PROJECT % (there, here or cwd) \
1500
+ if tezgah_store.mark("nudged", key) else None
1501
+ except Exception:
1502
+ return None
1503
+ return None
1504
+
1505
+
982
1506
  # --- task scope: the plan's own phase and path allowlist --------------------
983
1507
  # What this closes: a session that has declared what it is working on - a plan
984
1508
  # file under .tezgah/plans/open is active, so the work has a phase and the
@@ -1013,13 +1537,19 @@ TASK_PHASE_DENY = (
1013
1537
  "the plan's Acceptance, spike and checkpoint are in order.")
1014
1538
  TASK_SCOPE_DENY = (
1015
1539
  "Task gate: %s is outside the paths the active task %s allows (%s). If the "
1016
- "plan's work genuinely reaches it, widen the scope yourself with `tezgah-task "
1017
- "allow <glob> ... --why \"<reason>\"` (listing the current globs too); "
1018
- "otherwise leave the file alone.")
1540
+ "plan's work genuinely reaches it, widen the scope yourself (allow replaces "
1541
+ "the list, so this keeps the current globs): `%s --why \"<what in the plan "
1542
+ "reaches it>\"`; otherwise leave the file alone.")
1543
+ # a target outside the repository: an allow glob is repo-relative, so none reaches it
1544
+ TASK_SCOPE_OUTSIDE = (
1545
+ "Task gate: %s is outside the paths the active task %s allows (%s). It lies "
1546
+ "outside this repository, and an allow glob is relative to it, so no "
1547
+ "`tezgah-task allow` line reaches it; leave the file alone, or do the work "
1548
+ "under that repository's own task.")
1019
1549
  TASK_RECORD_DENY = (
1020
- "Task gate: %s is the active task's own record. The phase and the allowlist "
1021
- "live in that file and only `tezgah-task` writes them, because it checks the "
1022
- "plan's Acceptance, spike and review on the way; change the task through it.")
1550
+ "Task gate: %s is the active task's own record; only `tezgah-task` writes it, checking the plan's "
1551
+ "Acceptance, spike and review on the way. The phase and allowlist: `tezgah-task phase|allow`; an "
1552
+ "Acceptance box: `tezgah-task tick NNN <n>`; State/Next: `tezgah-task note NNN state|next \"...\"`.")
1023
1553
  # The shapes that make a shell command a write, for the phase rule below. The
1024
1554
  # tools are not the only way to change a file, and E7b measured the other one:
1025
1555
  # with `edit` and `write` refused twelve and six times, the armed arm wrote the
@@ -1067,12 +1597,138 @@ def _task(cwd, base, task):
1067
1597
  return task
1068
1598
  if tezgah_task is None:
1069
1599
  return None
1070
- 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]
1071
1726
 
1072
1727
 
1073
1728
  def task_shell_reason(inp, cwd, base, task=_UNRESOLVED):
1074
- """A deny reason when the active task's phase only reads and this shell
1075
- 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.
1076
1732
 
1077
1733
  The route to the hole the phase rule closes, and the one E7b measured: with
1078
1734
  the write tools refused, the armed arm wrote the target with a heredoc
@@ -1084,15 +1740,18 @@ def task_shell_reason(inp, cwd, base, task=_UNRESOLVED):
1084
1740
  write tool's path, while a positional target is not read at all (see
1085
1741
  write_paths), and the same fail-open holds: no record, no requirement."""
1086
1742
  task = _task(cwd, base, task)
1087
- if not task or task["phase"] in tezgah_task.WRITE_PHASES:
1088
- return None
1089
- 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 ""))):
1090
1744
  return None
1091
- 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
1092
1749
 
1093
1750
 
1094
1751
  def task_record_reason(inp, cwd, base, task=_UNRESOLVED):
1095
- """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`).
1096
1755
 
1097
1756
  The record is a file in the repo, so an agent that may write files could
1098
1757
  retype the phase instead of running the CLI, and skip the Acceptance, spike
@@ -1101,10 +1760,15 @@ def task_record_reason(inp, cwd, base, task=_UNRESOLVED):
1101
1760
  task = _task(cwd, base, task)
1102
1761
  if not task:
1103
1762
  return None
1104
- record = os.path.relpath(task["path"],
1105
- 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
1106
1769
  for path in write_paths(inp):
1107
- 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)):
1108
1772
  return TASK_RECORD_DENY % path
1109
1773
  return None
1110
1774
 
@@ -1120,25 +1784,69 @@ def task_reason(inp, cwd, base, task=_UNRESOLVED):
1120
1784
  hunk like every other write rule's here. A path that resolves outside the
1121
1785
  repo root is refused by `relative` before any pattern is tried - a `**`
1122
1786
  allowlist must not reach out of the repo - unless it is the session's own
1123
- scratch (`scratch_target`: a temp file, a device), which is no task's work;
1124
- 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
1125
1792
  names the file as the call spelled it, because the agent has to recognize
1126
- 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."""
1127
1799
  task = _task(cwd, base, task)
1128
1800
  if not task:
1129
1801
  return None
1130
- if task["phase"] not in tezgah_task.WRITE_PHASES:
1131
- return TASK_PHASE_DENY % (task["id"], task["phase"],
1132
- " or ".join(tezgah_task.WRITE_PHASES))
1133
- patterns = task["allowed_paths"]
1134
- if not patterns:
1802
+ # a held reading scopes nothing outside the plan worktrees (`_owners`)
1803
+ scope = None if task.get("held") else task
1804
+ # The targets the plan scopes: not a URI or a shell target with no known
1805
+ # value (unnamed_target), and not tezgah's own gitignored workspace under
1806
+ # `.tezgah/` (research lines, lessons, briefs) - the active record there is
1807
+ # task_record_reason's, refused before this rule runs. A call whose every
1808
+ # target is one of those writes no project file, so the phase does not
1809
+ # refuse it either; a call with no target at all still meets the phase.
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)
1814
+ project = []
1815
+ top = tezgah_task.repo_root(cwd, base) if cwd else None
1816
+ for spelled in paths:
1817
+ if unnamed_target(spelled, inp, top):
1818
+ continue
1819
+ path = _resolved(spelled, top, command)
1820
+ rel = tezgah_task.relative(path, cwd, base, worktrees=True)
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):
1829
+ continue
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))
1833
+ if paths and not project:
1135
1834
  return None
1136
- for path in write_paths(inp):
1137
- if scratch_target(path, 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)):
1138
1842
  continue
1139
- rel = tezgah_task.relative(path, cwd, base)
1140
1843
  if rel is None or not any(tezgah_task.match(rel, p) for p in patterns):
1141
- return TASK_SCOPE_DENY % (path, task["id"], ", ".join(patterns))
1844
+ # the call's own spelling: the agent has to recognize its argument
1845
+ if rel is None:
1846
+ return TASK_SCOPE_OUTSIDE % (spelled, own["id"], ", ".join(patterns))
1847
+ ready = " ".join(["tezgah-task allow"] + [shlex.quote(p) for p in
1848
+ list(patterns) + [rel]])
1849
+ return TASK_SCOPE_DENY % (spelled, own["id"], ", ".join(patterns), ready)
1142
1850
  return None
1143
1851
 
1144
1852
 
@@ -1188,9 +1896,11 @@ CHECKPOINT_DENY = (
1188
1896
 
1189
1897
 
1190
1898
  def checkpoint_reason(inp, cwd, base, task=_UNRESOLVED):
1191
- """A deny reason when the active task is in `implementation`, its plan's
1192
- `checkpoint:` waits for a commit (`pending <sha>`), that sha is still HEAD,
1193
- 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.
1194
1904
 
1195
1905
  The recorded sha, not the word `pending`, is the switch: the commit the
1196
1906
  refusal names moves HEAD off it, so the refusal clears by itself. The section
@@ -1203,15 +1913,29 @@ def checkpoint_reason(inp, cwd, base, task=_UNRESOLVED):
1203
1913
  # a shell line that changes no file is not this rule's, the same read of
1204
1914
  # the same shape table task_shell_reason makes
1205
1915
  return None
1916
+ paths = write_paths(inp)
1917
+ if command is None and paths and all(URI_TARGET.match(p) for p in paths):
1918
+ return None # a tool device or a message changes no tree state
1206
1919
  task = _task(cwd, base, task)
1207
- 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":
1208
1933
  return None
1209
1934
  sha = tezgah_task.checkpoint_sha(task.get("checkpoint"))
1210
1935
  if not sha:
1211
1936
  return None
1212
1937
  # HEAD read from the ref file, not a fork: this question is asked on every
1213
1938
  # write of the phase, and an unreadable HEAD fails open.
1214
- root = tezgah_task.repo_root(cwd, base)
1215
1939
  if _head(root) != sha:
1216
1940
  return None
1217
1941
  import subprocess # deferred: only a plan still pending its checkpoint pays
@@ -1222,7 +1946,8 @@ def checkpoint_reason(inp, cwd, base, task=_UNRESOLVED):
1222
1946
  return None
1223
1947
  if listed.returncode != 0 or not listed.stdout.strip():
1224
1948
  return None
1225
- 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")))
1226
1951
 
1227
1952
 
1228
1953
  # --- workspace: per-project tezgah state lives under <repo>/.tezgah only ------
@@ -1276,6 +2001,18 @@ def workspace_reason(inp, cwd, base):
1276
2001
  # or whose target is a positional argument (`cp`, `sed -i`), leaves a row with
1277
2002
  # no `key` or no row at all - the gate cannot read the line it adds - so the
1278
2003
  # context cannot label that line.
2004
+ #
2005
+ # History, whatever the turn read (plan 077, rsi-borrow-screen idea 2): a write
2006
+ # that rewrites or removes a ledger line leaves one `lesson_changed` row per such
2007
+ # line, keyed by the old line and carrying its text, so a standing constraint
2008
+ # that was edited away can be read back from the ledger. Still never a refusal.
2009
+ # ponytail: a shell write the gate cannot read the new text of (`sed -i`, an
2010
+ # `echo >` body) leaves no `lesson_changed` row, and a rewritten line carries no
2011
+ # `new_key`: the row contract (`tezgah_integrity.LEDGER_FIELDS`) has no field
2012
+ # for it, so the line's new key rides only its `lesson_tainted` row, if any.
2013
+ LESSON_CHANGED_MAX = 10
2014
+
2015
+
1279
2016
  def lesson_taint(tool, inp, cwd, session_id, agent=None, shell_body=None):
1280
2017
  """Write one `lesson_tainted` row per lesson line this call leaves in a
1281
2018
  ledger that the ledger did not hold, when the user turn already holds a row
@@ -1285,24 +2022,28 @@ def lesson_taint(tool, inp, cwd, session_id, agent=None, shell_body=None):
1285
2022
  the call lands (`_after_text`), so a partial-line edit names the line it
1286
2023
  leaves, not its fragment. The keyed rows also go to the ledger's repository
1287
2024
  index (`tezgah_lessons.write_taint`), which the context labels lines from.
2025
+ In any turn, one `lesson_changed` row per ledger line the call drops - its
2026
+ old `key`, its old text in `detail` (redacted by `note`), the ledger in
2027
+ `target`: read off the post-write text, a patch's `-` lines, or a shell
2028
+ heredoc that overwrites the file; an append drops nothing.
1288
2029
  Returns nothing: it is never a refusal."""
1289
- if tezgah_lessons is None or turn_rows is None or not session_id:
2030
+ if tezgah_lessons is None or not session_id:
1290
2031
  return
1291
2032
  t = str(tool or "").lower()
1292
2033
 
1293
2034
  def norm(path):
1294
2035
  return os.path.normpath(os.path.join(cwd, os.path.expanduser(path)))
1295
2036
 
1296
- texts = {norm(path): new for path, _old, new in
1297
- write_texts(inp if t in WRITE_TOOLS else shell_body)}
2037
+ written = write_texts(inp if t in WRITE_TOOLS else shell_body)
2038
+ texts = {norm(path): new for path, _old, new in written}
1298
2039
  targets = sorted(p for p in set(texts) | set(map(norm, write_paths(inp)))
1299
2040
  if tezgah_lessons.is_ledger(p))
1300
2041
  if not targets:
1301
2042
  return
1302
2043
  source = next((row["source"] for row in reversed(
1303
- turn_rows(session_id, agent=agent)) if row.get("source")), None)
1304
- if not source:
1305
- return
2044
+ turn_rows(session_id, agent=agent)) if row.get("source")),
2045
+ None) if turn_rows else None
2046
+ minus = {norm(path): old for path, old, _new in written}
1306
2047
  key_of = tezgah_lessons.lesson_key
1307
2048
  for path in targets:
1308
2049
  try:
@@ -1310,23 +2051,68 @@ def lesson_taint(tool, inp, cwd, session_id, agent=None, shell_body=None):
1310
2051
  current = fh.read()
1311
2052
  except OSError:
1312
2053
  current = ""
1313
- have = set(map(key_of, tezgah_lessons.entries(current)))
2054
+ lines = tezgah_lessons.entries(current)
2055
+ have = set(map(key_of, lines))
1314
2056
  new = texts.get(path)
2057
+ gone = ()
1315
2058
  if t in WRITE_TOOLS and new is not None:
1316
2059
  after = _after_text(inp, current)
1317
- new = new if after is None else after # None: a patch's `+` lines
2060
+ if after is None and path in _patch_deletes(inp, norm):
2061
+ gone = list(dict.fromkeys(lines)) # a patch's Delete File
2062
+ elif after is None: # a patch: its `-` lines leave, its `+` lines land
2063
+ gone = _dropped(tezgah_lessons.entries(minus.get(path) or ""), new)
2064
+ else:
2065
+ new, gone = after, _dropped(lines, after)
2066
+ elif new is not None and not _shell_appends(inp.get("command")):
2067
+ gone = _dropped(lines, new)
2068
+ fields = dict(target=path, agent=agent, workspace=root_for(cwd),
2069
+ id=call_id(tool, inp))
2070
+ # a rewrite of the whole ledger would otherwise push the refusal rules'
2071
+ # fixed tails (the last verify, prior calls) out of their window
2072
+ for line in gone[:LESSON_CHANGED_MAX]:
2073
+ note(session_id, "lesson_changed", line, key=key_of(line), **fields)
2074
+ if len(gone) > LESSON_CHANGED_MAX:
2075
+ rest = len(gone) - LESSON_CHANGED_MAX
2076
+ note(session_id, "lesson_changed", "%d more lines changed" % rest,
2077
+ items=rest, **fields)
2078
+ if not source:
2079
+ continue
1318
2080
  keys = [k for k in dict.fromkeys(map(key_of, tezgah_lessons.entries(
1319
2081
  new or ""))) if k not in have] or [None]
1320
- fields = dict(source=source, target=path, agent=agent,
1321
- workspace=root_for(cwd), id=call_id(tool, inp))
1322
2082
  for key in keys:
1323
- note(session_id, "lesson_tainted", source, key=key, **fields)
2083
+ note(session_id, "lesson_tainted", source, key=key, source=source,
2084
+ **fields)
1324
2085
  if keys != [None]:
1325
2086
  write_taint(os.path.dirname(os.path.dirname(path)), have | set(keys),
1326
2087
  [{"key": k, "source": source, "ts": int(time.time())}
1327
2088
  for k in keys])
1328
2089
 
1329
2090
 
2091
+ PATCH_DELETE = re.compile(r"^\*\*\* Delete File: (.+?)\s*$", re.M)
2092
+
2093
+
2094
+ def _patch_deletes(inp, norm):
2095
+ """The paths an apply_patch input deletes (`*** Delete File: <path>`), normed;
2096
+ read from the patch text itself, so a hunk of blank lines is not a delete."""
2097
+ text = "\n".join(v for v in (inp or {}).values() if isinstance(v, str))
2098
+ return {norm(p) for p in PATCH_DELETE.findall(text)}
2099
+
2100
+
2101
+ def _dropped(lines, after):
2102
+ """The ledger `lines` the text `after` no longer holds, once each, in order."""
2103
+ keep = set(map(tezgah_lessons.lesson_key, tezgah_lessons.entries(after)))
2104
+ return [ln for ln in dict.fromkeys(lines)
2105
+ if tezgah_lessons.lesson_key(ln) not in keep]
2106
+
2107
+
2108
+ def _shell_appends(command):
2109
+ """Whether a shell write's body is added to its target (`>>`, `tee -a`) rather
2110
+ than replacing it; True when the target is not read (no row beats a false one)."""
2111
+ m = SHELL_TARGET.search(mask(str(command or "")))
2112
+ return not m or m.group(0).startswith(">>") or bool(
2113
+ re.search(r"\s-(?:[A-Za-z]*a[A-Za-z]*|-append)\s", m.group(0)))
2114
+
2115
+
1330
2116
  # --- the shell's write body: three write-tool rules reached through a heredoc -
1331
2117
  # What this closes (the route E7c measured): SKIP_TEST, ATTRIB_LINE and the
1332
2118
  # prefixed-token credential scan (secret_edit) read the text a write tool lands,
@@ -1376,8 +2162,12 @@ def shell_target(command):
1376
2162
  if not SHELL_WRITE.search(masked):
1377
2163
  return ""
1378
2164
  m = SHELL_TARGET.search(masked)
1379
- if not m:
1380
- 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`."""
1381
2171
  start, end = m.span(1) if m.group(1) else m.span(2)
1382
2172
  raw = c[start:end]
1383
2173
  # `\S+` runs to the next whitespace, so the separator of a chained command
@@ -1390,6 +2180,16 @@ def shell_target(command):
1390
2180
  return raw.strip("'\"").rstrip(";|&)")
1391
2181
 
1392
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
+
1393
2193
  def shell_write_body(command, cwd=None):
1394
2194
  """The `{file_path, content}` a shell command writes into a file, or None when
1395
2195
  this command writes no file's content of its own.
@@ -1570,6 +2370,48 @@ def effectful(tool, inp):
1570
2370
  return mcp_class(tool) is not None
1571
2371
 
1572
2372
 
2373
+ # --- ready: an idle `wait` while ready steps are open (plan 086, S1) ----------
2374
+ # omp's `wait` blocks the parent until a job settles. Measured over 15 omp
2375
+ # sessions, 68.3% of spawns were followed by `wait` with no other call between,
2376
+ # and at 86 of the 163 such waits that had a todo list two or more items were
2377
+ # open. The owner's default is a warning, never a deny: the line rides back with
2378
+ # the wait's result, as the graph line does (`graph_advice`).
2379
+ READY_WAIT = (
2380
+ "tezgah: idle wait - nothing ran since the spawn while %d ready todo "
2381
+ "step(s) are open: %s. Dispatch every ready step now, independent ones as "
2382
+ "subagents in one message, or mark it blocked on the slice it waits for. "
2383
+ "Ending the turn is the non-blocking alternative to `wait`: the host wakes "
2384
+ "you on the result.")
2385
+
2386
+
2387
+ def ready_advice(tool, session_id, cwd=None):
2388
+ """The idle-wait warning for a `wait` call, else None: the newest spawn in
2389
+ the ledger tail is followed by no call but waits and todo updates, and the
2390
+ session's newest `todo` row (`tezgah_integrity.newest_todo`) holds a ready
2391
+ item no open slice of this session is working on
2392
+ (`tezgah_integrity.ready_heads`). A row with no `tool` (a turn marker, a
2393
+ `spawn` row) is no call. Off under `orchestrate-off`, whose rule text it
2394
+ backs."""
2395
+ if str(tool or "").lower() != "wait" or not session_id:
2396
+ return None
2397
+ if off("orchestrate-off"):
2398
+ return None
2399
+ rows = events(session_id, tail=DRIFT_TAIL)
2400
+ for row in reversed(rows):
2401
+ name = str(row.get("tool") or "").lower()
2402
+ if name in SUBAGENT_TOOLS:
2403
+ break
2404
+ if name and name != "wait" and name not in TODO_TOOLS:
2405
+ return None
2406
+ else:
2407
+ return None
2408
+ todo = newest_todo(reversed(rows))
2409
+ heads = ready_heads(todo, session_id, cwd) if todo else []
2410
+ if not heads:
2411
+ return None
2412
+ return READY_WAIT % (len(heads), "; ".join(cut(h, 48) for h in heads[:3]))
2413
+
2414
+
1573
2415
  # --- plan required: work that spans files on main has no plan ----------------
1574
2416
  # What this closes: a turn that wrote four product files - a hook, its tests, its
1575
2417
  # docs and the changelog - straight onto `main`, with the plan opened afterwards.
@@ -1684,9 +2526,10 @@ def _head(root):
1684
2526
  Read from `.git/HEAD` - the ref file, or the packed-refs line beside it -
1685
2527
  rather than a `git` fork, the way `_branch` reads the branch: the checkpoint
1686
2528
  rule asks this on every write of an `implementation` phase, and an
1687
- unanswerable question must pass. ponytail: a worktree's packed refs live in
1688
- the common dir, not in the worktree gitdir this reads, so a repacked ref in a
1689
- 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)."""
1690
2533
  gitdir = _gitdir(root)
1691
2534
  if not gitdir:
1692
2535
  return ""
@@ -1699,6 +2542,11 @@ def _head(root):
1699
2542
  if not ref.startswith("ref: "):
1700
2543
  return ref if _SHA.fullmatch(ref) else ""
1701
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
1702
2550
  try:
1703
2551
  with open(os.path.join(gitdir, name), encoding="utf-8",
1704
2552
  errors="replace") as fh:
@@ -2249,11 +3097,9 @@ WRAPPER_VALUE_OPTS = {"sudo": ("-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "
2249
3097
  INTERPRETER_VALUE_OPTS = ("-X", "-W", "-o", "+o", "-O")
2250
3098
 
2251
3099
 
2252
- def _program(words):
2253
- """(program basename without `.py`, its arguments) for one simple command,
2254
- past compound keywords, env assignments, the wrappers (WRAPPER_VALUE_OPTS,
2255
- by basename, with their options) and an interpreter's options before the
2256
- 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)."""
2257
3103
  i = 0
2258
3104
  while i < len(words):
2259
3105
  word = words[i]
@@ -2268,7 +3114,14 @@ def _program(words):
2268
3114
  i += 2 if words[i] in WRAPPER_VALUE_OPTS[wrapper] else 1
2269
3115
  if wrapper == "timeout" and i < len(words):
2270
3116
  i += 1 # the duration
2271
- 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)
2272
3125
  if words and words[0] == "[":
2273
3126
  # the test command: its operands are not a program, and the command
2274
3127
  # after the `]` is (`if [ -d x ]; then touch y; fi`)
@@ -2635,13 +3488,23 @@ def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
2635
3488
  # one. A shell command that writes a file through a redirect or `tee` is a
2636
3489
  # write here too: the rules below read its target from `write_paths`, the
2637
3490
  # reader `capture` and the PostToolUse row already share - unless all it
2638
- # 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`.
2639
3492
  writes = t in WRITE_TOOLS or (t in BASH_TOOLS and any(
2640
- not scratch_target(p, cwd) for p in write_paths(inp)))
3493
+ not _scratch(p, cwd) for p in write_paths(inp)))
2641
3494
  if writes and RACE_REFUSE:
2642
3495
  reason = race_reason(inp, session_id, cwd)
2643
3496
  if reason:
2644
3497
  return _deny(session_id, "race", reason, tool, inp, base)
3498
+ # Lease: a write into a file an open sibling slice owns, or into the main
3499
+ # checkout by a slice whose record names a worktree (see LEASE_DENY). After
3500
+ # the race rule, which names a write already lost; before the task scope,
3501
+ # since the allowlist is the plan's and the lease is one slice's share of it.
3502
+ # It backs the fan-out rule's text, so its switch is that rule's; with the
3503
+ # journal off there is no record to read.
3504
+ if writes and not off("orchestrate-off") and not off("slices-off"):
3505
+ reason = lease_reason(inp, session_id, cwd, agent)
3506
+ if reason:
3507
+ return _deny(session_id, "lease", reason, tool, inp, base)
2645
3508
  # Task scope: the active plan's phase and the files its allowlist names (see
2646
3509
  # task_reason). The record itself is refused first of all: only
2647
3510
  # bin/tezgah-task writes it, through the checks it makes on the way.
@@ -2650,8 +3513,12 @@ def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
2650
3513
  # `.tezgah/plans/open/` and parses a plan, and this path is every write of
2651
3514
  # every session. Reads and non-write tools pay nothing.
2652
3515
  task = _UNRESOLVED
3516
+ _WORKTREE_TASKS.clear()
2653
3517
  if tezgah_task is not None and t in WRITE_TOOLS + BASH_TOOLS:
2654
- 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)
2655
3522
  if writes:
2656
3523
  reason = task_record_reason(inp, cwd, base, task)
2657
3524
  if reason:
@@ -2805,8 +3672,9 @@ def _dry_decision(tool, inp, cwd, session_id, agent=None):
2805
3672
  """`decision` with every write it makes swapped out for the call.
2806
3673
 
2807
3674
  The writers are module names `decision` reaches through (`note` for the
2808
- deny, drift, nudge and lesson_tainted rows, `write_taint` for the lesson
2809
- taint index, `first_nudge` for the once-per-session mark, `capture` for the
3675
+ deny, drift, nudge, lesson_tainted and lesson_changed rows, `write_taint`
3676
+ for the lesson taint index, `first_nudge` for the once-per-session mark,
3677
+ `capture` for the
2810
3678
  snapshot), so one swap covers every rule and a new rule
2811
3679
  that records through them is dry here without being told. Safe because
2812
3680
  the dry run is its own process (bin/tezgah-gate check): nothing else in it
@@ -2836,6 +3704,54 @@ def _dry_decision(tool, inp, cwd, session_id, agent=None):
2836
3704
  DENY_RULES = {"control": None, "explorer": None, "shortcut": "verify-off",
2837
3705
  "piped": "verify-off",
2838
3706
  "attribution": None, "lang": "lang-off", "race": None,
3707
+ "lease": "orchestrate-off",
2839
3708
  "task": "task-off", "workspace": "workspace-off", "secret": None,
2840
3709
  "plan": None, "order": "verify-off", "loop": "verify-off",
2841
3710
  "retry": "verify-off", "drift": "reminder-off"}
3711
+
3712
+
3713
+ # --- ask: an ask-tool question with no recommended option (plan 086, S3) -----
3714
+ # The ask policy's structured half: omp's `ask` and Claude's AskUserQuestion
3715
+ # carry their questions as data, so the check needs no word list for the
3716
+ # question itself. 7 of 51 distinct `ask` questions in the owner's omp
3717
+ # transcripts carried no `recommended` option (research orchestrator-flow-audit
3718
+ # S3, finding 4). A warning, never a deny (the owner's 2026-09-26 decision
3719
+ # removed the per-command consent gate): the line rides back with the call's
3720
+ # result, as the graph line does (`graph_advice`). Every such call leaves an
3721
+ # `ask` row, the counts `tezgah-gate replay` and the ledger read.
3722
+ ASK_NO_DEFAULT = (
3723
+ "tezgah: %d of %d question(s) in this %s call carry no "
3724
+ "recommended option. Each question put to the user names a recommended "
3725
+ "default (omp `ask`: `recommended`; AskUserQuestion: a \"({RECOMMENDED})\" "
3726
+ "option), and the work proceeds on it unless the step waits on the user's "
3727
+ "answer by nature (a force push, a delete of what the session did not "
3728
+ "make, production, a credential, a spend over the cap). ")
3729
+ ASK_GRANTED = (
3730
+ "tezgah: the user already granted %s this session; that step is taken, "
3731
+ "not asked again.")
3732
+
3733
+
3734
+ def ask_advice(tool, inp, session_id, cwd=None):
3735
+ """The ask-policy warning for an ask-tool call, else None: a question with
3736
+ no recommended option (`tezgah_integrity.undefaulted`), or one about an
3737
+ outward step the user granted this session (`grants_in_force`). Records
3738
+ one `ask` row per call with the counts. Off under `verify-off`, whose Stop
3739
+ rule the policy's other half rides, and `pretooluse-off`."""
3740
+ from tezgah_integrity import (ASK_TOOLS, _said as said, grants_in_force,
3741
+ never_default, outward_classes, undefaulted)
3742
+ name = str(tool or "").lower()
3743
+ if name not in ASK_TOOLS or not session_id or not (cwd and root_for(cwd)) \
3744
+ or off("pretooluse-off") or off("verify-off"):
3745
+ return None
3746
+ asked, defaulted = undefaulted(inp)
3747
+ if not asked:
3748
+ return None
3749
+ qs = inp.get("questions") if isinstance(inp.get("questions"), list) else [inp]
3750
+ text = " ".join(str(q.get("question") or "") for q in qs if isinstance(q, dict))
3751
+ note(session_id, "ask", cut(" ".join(text.split()), 120), tool=str(tool),
3752
+ questions=asked, defaults=defaulted)
3753
+ out = ASK_NO_DEFAULT % (asked - defaulted, asked, tool) if defaulted < asked else ""
3754
+ granted = outward_classes(text) & grants_in_force(session_id)
3755
+ if granted and not never_default(text):
3756
+ out += ASK_GRANTED % ", ".join(sorted(granted))
3757
+ return said(out.strip()) if out else None