@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.
- package/CHANGELOG.md +140 -1
- package/MANIFEST +7 -0
- package/README.md +6 -3
- package/bin/tezgah-decisions +6 -14
- package/bin/tezgah-design +21 -5
- package/bin/tezgah-docs +18 -16
- package/bin/tezgah-doctor +152 -0
- package/bin/tezgah-lessons +4 -1
- package/bin/tezgah-research +249 -17
- package/bin/tezgah-route +70 -8
- package/bin/tezgah-setup +26 -13
- package/bin/tezgah-task +877 -60
- package/bin/tezgah-taste +56 -15
- package/docs/architecture.md +28 -11
- package/docs/clarity-baseline.json +2 -2
- package/docs/contract.md +108 -24
- package/docs/environment.md +3 -1
- package/docs/evidence.md +209 -28
- package/docs/feature-audit.md +1 -1
- package/docs/gate.md +78 -23
- package/docs/glossary.md +4 -4
- package/docs/hosts.md +8 -8
- package/docs/index.json +11 -5
- package/docs/judge.md +9 -3
- package/docs/models.md +75 -14
- package/docs/operations.md +133 -3
- package/docs/research.md +349 -15
- package/docs/skills.md +1 -1
- package/docs/status-line.md +5 -5
- package/docs/testing.md +20 -13
- package/docs/vision.md +12 -4
- package/hooks/hooks.json +2 -2
- package/hooks/lang/en.json +323 -0
- package/hooks/lang/tr.json +344 -0
- package/hooks/projects-auto-init.py +29 -1
- package/hooks/projects-posttooluse.py +24 -5
- package/hooks/projects-pretooluse.py +9 -4
- package/hooks/projects-stop.py +28 -16
- package/hooks/tezgah_agents.py +53 -17
- package/hooks/tezgah_clarity.py +7 -3
- package/hooks/tezgah_context.py +489 -438
- package/hooks/tezgah_gate.py +996 -80
- package/hooks/tezgah_integrity.py +1547 -197
- package/hooks/tezgah_models.py +427 -45
- package/hooks/tezgah_paths.py +147 -16
- package/hooks/tezgah_policy.py +261 -129
- package/hooks/tezgah_rank.py +29 -27
- package/hooks/tezgah_replay.py +200 -30
- package/hooks/tezgah_research.py +1303 -14
- package/hooks/tezgah_stopspec.py +7 -0
- package/hooks/tezgah_store.py +74 -27
- package/hooks/tezgah_task.py +1201 -40
- package/hooks/tezgah_taste.py +8 -4
- package/hosts/codex/hook.py +32 -13
- package/hosts/cursor/hook.py +32 -14
- package/hosts/dsh/hooks.json +2 -2
- package/hosts/omp/hook.py +44 -23
- package/hosts/omp/tezgah-hook.ts.in +93 -7
- package/hosts/opencode/plugins/tezgah.js +273 -6
- package/output-styles/tezgah.md +76 -42
- package/package.json +1 -1
- package/skills/i-have-adhd/EVALS.md +22 -2
- package/skills/i-have-adhd/SKILL.md +12 -5
- package/skills/plan-add/SKILL.md +27 -11
- package/skills/plan-add/render_table.py +8 -3
- package/skills/plan-sync/SKILL.md +5 -3
- package/skills/ponytail/EVALS.md +21 -2
- package/skills/ponytail/SKILL.md +4 -4
- package/skills/research/SKILL.md +89 -1
- package/skills/tezgah-contract/EVALS.md +52 -7
- package/skills/tezgah-contract/SKILL.md +154 -59
package/hooks/tezgah_gate.py
CHANGED
|
@@ -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,
|
|
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
|
|
798
|
-
|
|
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
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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:
|
|
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
|
-
|
|
952
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
|
1017
|
-
"
|
|
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
|
|
1021
|
-
"
|
|
1022
|
-
"
|
|
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
|
|
1075
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
|
1137
|
-
if
|
|
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
|
-
|
|
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
|
|
1192
|
-
|
|
1193
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
1297
|
-
|
|
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")),
|
|
1304
|
-
|
|
1305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
1380
|
-
|
|
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.
|
|
1688
|
-
|
|
1689
|
-
|
|
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
|
|
2253
|
-
"""
|
|
2254
|
-
|
|
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
|
-
|
|
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 (`
|
|
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
|
|
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
|
-
|
|
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
|
|
2809
|
-
taint index, `first_nudge` for the once-per-session mark,
|
|
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
|