@r1z4x/tezgah 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -1
- package/MANIFEST +5 -0
- package/README.md +6 -3
- package/bin/tezgah-docs +18 -16
- package/bin/tezgah-doctor +152 -0
- package/bin/tezgah-research +234 -10
- package/bin/tezgah-route +48 -1
- package/bin/tezgah-setup +26 -13
- package/bin/tezgah-task +589 -17
- package/bin/tezgah-taste +48 -10
- package/docs/architecture.md +7 -4
- package/docs/clarity-baseline.json +2 -2
- package/docs/contract.md +108 -24
- package/docs/evidence.md +201 -28
- package/docs/feature-audit.md +1 -1
- package/docs/gate.md +60 -22
- 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 +35 -2
- package/docs/operations.md +103 -3
- package/docs/research.md +346 -13
- package/docs/skills.md +1 -1
- package/docs/status-line.md +5 -5
- package/docs/testing.md +2 -2
- 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-posttooluse.py +24 -5
- package/hooks/projects-pretooluse.py +9 -4
- package/hooks/projects-stop.py +28 -16
- package/hooks/tezgah_agents.py +29 -10
- package/hooks/tezgah_clarity.py +7 -3
- package/hooks/tezgah_context.py +425 -406
- package/hooks/tezgah_gate.py +583 -33
- package/hooks/tezgah_integrity.py +1480 -197
- package/hooks/tezgah_models.py +337 -15
- package/hooks/tezgah_paths.py +103 -10
- package/hooks/tezgah_policy.py +258 -126
- package/hooks/tezgah_rank.py +29 -27
- package/hooks/tezgah_replay.py +200 -30
- package/hooks/tezgah_research.py +1294 -9
- package/hooks/tezgah_stopspec.py +7 -0
- package/hooks/tezgah_store.py +74 -27
- package/hooks/tezgah_task.py +915 -6
- package/hosts/codex/hook.py +9 -10
- package/hosts/cursor/hook.py +21 -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 +102 -2
- 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 +5 -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 +151 -56
package/hooks/tezgah_gate.py
CHANGED
|
@@ -108,6 +108,7 @@ from tezgah_integrity import (BASH_TOOLS, BEGAN_KIND, ENV_WORD, GIT_VALUE_OPTS,
|
|
|
108
108
|
prior_calls, scratch_target, shortcut_command,
|
|
109
109
|
shortcut_edit, turn_rows, verify_command,
|
|
110
110
|
write_texts)
|
|
111
|
+
from tezgah_integrity import SUBAGENT_TOOLS, TODO_TOOLS, newest_todo, ready_heads
|
|
111
112
|
from tezgah_paths import (CACHE, CONFIG_DIR, HOST_DIRS, OFF_DIRS, PLUGIN_ROOT,
|
|
112
113
|
REPO_MARKS, SWITCHES, cache_dir, fallback_cache,
|
|
113
114
|
linked_main, off, root_for, roots)
|
|
@@ -124,9 +125,9 @@ except ImportError: # pragma: no cover - only on an integrity module without th
|
|
|
124
125
|
try: # The race rule reads the write history through tezgah_integrity; that
|
|
125
126
|
# reader is newer than some checkouts of the module, and a missing name must
|
|
126
127
|
# cost the rule, never the session (this import runs on every gated call).
|
|
127
|
-
from tezgah_integrity import writers_elsewhere
|
|
128
|
+
from tezgah_integrity import spawn_of, writers_elsewhere
|
|
128
129
|
except ImportError: # pragma: no cover - only on an integrity module without it
|
|
129
|
-
writers_elsewhere = None
|
|
130
|
+
spawn_of = writers_elsewhere = None
|
|
130
131
|
|
|
131
132
|
try: # The bytes a write is about to change. The snapshot module is newer than
|
|
132
133
|
# some checkouts too, and a missing module must cost the snapshot, never the
|
|
@@ -896,11 +897,23 @@ RACE_WINDOW_MIN = 10
|
|
|
896
897
|
# overwrite, so this rule cannot be a soft one.
|
|
897
898
|
RACE_REFUSE = True
|
|
898
899
|
RACE_DENY = (
|
|
899
|
-
"Concurrent write refused:
|
|
900
|
+
"Concurrent write refused: %s wrote this file within the last %d "
|
|
900
901
|
"minutes, so the copy of %s in your context may already be stale. Re-read "
|
|
901
902
|
"the file and re-apply your change to what is on disk now; if both sessions "
|
|
902
903
|
"are editing it, say so and let one of them own the file rather than "
|
|
903
904
|
"overwriting the other's work.")
|
|
905
|
+
# How RACE_DENY names a writer, by its relation to this session
|
|
906
|
+
# (tezgah_integrity._relation): the bare ledger id sent a model to the wrong
|
|
907
|
+
# cause - it called an omp fork "a second window of the same session"
|
|
908
|
+
# (research orchestrator-flow-audit S7, finding 4).
|
|
909
|
+
RACE_WHO = {
|
|
910
|
+
"child": "your own subagent %s (ledger %s), not ended in the slice journal",
|
|
911
|
+
"parent": "your parent session (ledger %s)",
|
|
912
|
+
"sibling": "sibling subagent %s of your parent (ledger %s)",
|
|
913
|
+
"fork": "an omp fork of this session (ledger %s)",
|
|
914
|
+
"forked-from": "the session this one was forked from (ledger %s)",
|
|
915
|
+
"unrelated": "another, unrelated session %s",
|
|
916
|
+
}
|
|
904
917
|
# The file a write call names, per host dialect, plus the apply_patch headers
|
|
905
918
|
# (tezgah_integrity.PATCH_FILE) for the dialect whose paths live in the body.
|
|
906
919
|
# The race guard resolves each against the call's cwd to an absolute real path
|
|
@@ -911,8 +924,27 @@ RACE_DENY = (
|
|
|
911
924
|
# tool device, never a file on disk. Deliberately a list, not `scheme://`: a
|
|
912
925
|
# file reached through a URI scheme is still a file two sessions can race on.
|
|
913
926
|
URI_CHANNEL = re.compile(
|
|
914
|
-
r"^(?:agent|xd|local|artifact|proc|skill|mcp|omp|issue|pr|history|ssh|cfg
|
|
915
|
-
re.I)
|
|
927
|
+
r"^(?:agent|xd|local|artifact|proc|skill|mcp|omp|issue|pr|history|ssh|cfg|"
|
|
928
|
+
r"conflict)://", re.I)
|
|
929
|
+
# The task rule's wider reading: ANY scheme. Its question is "is this a path in
|
|
930
|
+
# the checkout", and a URI is not one - `tezgah_task.relative` would read
|
|
931
|
+
# `xd://mcp__codegraph_explore` as a relative file and refuse a name that exists
|
|
932
|
+
# nowhere (a user session, 2026-10-11: `xd://` and `conflict://` writes refused
|
|
933
|
+
# as outside the allowlist).
|
|
934
|
+
# `file://` is the one scheme that names a checkout file, so it stays guarded,
|
|
935
|
+
# as the race rule already reads it.
|
|
936
|
+
URI_TARGET = re.compile(r"^(?!file://)[A-Za-z][A-Za-z0-9+.-]*://", re.I)
|
|
937
|
+
# A shell target still holding a variable or a substitution once write_paths
|
|
938
|
+
# expanded what it could: the shell names the file at run time, the gate cannot.
|
|
939
|
+
UNEXPANDED = re.compile(r"\$[A-Za-z_{(]|`")
|
|
940
|
+
# One unexpanded piece of a shell target, for `known_tail`.
|
|
941
|
+
UNEXPANDED_PIECE = re.compile(
|
|
942
|
+
r"\$\{[^}]*\}|\$\([^)]*\)|\$[A-Za-z_][A-Za-z0-9_]*|`[^`]*`")
|
|
943
|
+
# `NAME=value` at a word start, and the `$NAME`/`${NAME}` a later word reads.
|
|
944
|
+
SHELL_ASSIGN = re.compile(
|
|
945
|
+
r"(?:^|(?<=[\s;&|(]))([A-Za-z_][A-Za-z0-9_]*)="
|
|
946
|
+
r"((?:'[^']*'|\"[^\"]*\"|[^\s;&|)'\"])*)")
|
|
947
|
+
SHELL_VAR = re.compile(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))")
|
|
916
948
|
WRITE_PATH = ("file_path", "filePath", "path", "notebook_path")
|
|
917
949
|
# The write-tool name a shell write's target is handed to `capture` under. capture
|
|
918
950
|
# takes a write tool's own path field and no shell tool name, and the row it
|
|
@@ -948,8 +980,107 @@ def write_paths(inp):
|
|
|
948
980
|
paths = [m.group(1) for m in PATCH_FILE.finditer(str(inp.get("patch") or ""))]
|
|
949
981
|
if paths:
|
|
950
982
|
return paths
|
|
951
|
-
|
|
952
|
-
|
|
983
|
+
command = str(inp.get("command") or inp.get("cmd") or "")
|
|
984
|
+
target = shell_target(command)
|
|
985
|
+
return [os.path.expanduser(_expand(target, command))] if target else []
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _expand(target, command):
|
|
989
|
+
"""`target` with each `$VAR`/`${VAR}` the shell would substitute: an
|
|
990
|
+
assignment earlier on the same line (`B=/tmp/x; git diff > $B/f`), else the
|
|
991
|
+
environment. A name with no known value stays as spelled, so
|
|
992
|
+
`unnamed_target` can tell the gate it does not know the file. ponytail: the
|
|
993
|
+
assignments are read off the raw text, so `echo "a B=x"` counts as one; that
|
|
994
|
+
only matters when the target then reads `$B`."""
|
|
995
|
+
env = dict(os.environ)
|
|
996
|
+
|
|
997
|
+
def sub(text):
|
|
998
|
+
return SHELL_VAR.sub(
|
|
999
|
+
lambda m: env.get(m.group(1) or m.group(2), m.group(0)), text)
|
|
1000
|
+
|
|
1001
|
+
cut = command.find(target)
|
|
1002
|
+
for m in SHELL_ASSIGN.finditer(command[:cut if cut >= 0 else len(command)]):
|
|
1003
|
+
name, value = m.group(1), m.group(2)
|
|
1004
|
+
if "$(" in value or "`" in value:
|
|
1005
|
+
env.pop(name, None) # a command's output: unknown here
|
|
1006
|
+
elif value[:1] == "'":
|
|
1007
|
+
env[name] = value.strip("'")
|
|
1008
|
+
else:
|
|
1009
|
+
env[name] = sub(value.strip('"'))
|
|
1010
|
+
return sub(target)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def unnamed_target(path, inp, top=None):
|
|
1014
|
+
"""True when a write target is no file the task rule can place in the
|
|
1015
|
+
checkout: a URI (a host tool device such as `xd://<tool>`, an agent message,
|
|
1016
|
+
a `conflict://N` resolution) or a shell target whose variable has no known
|
|
1017
|
+
value and that `known_tail` cannot place. Refusing either names a path
|
|
1018
|
+
that does not exist. A write tool's own path is never read for `$`:
|
|
1019
|
+
`Outer$Inner.java` is a real file name."""
|
|
1020
|
+
if URI_TARGET.match(path):
|
|
1021
|
+
return True
|
|
1022
|
+
if not isinstance(inp, dict):
|
|
1023
|
+
return False
|
|
1024
|
+
command = str(inp.get("command") or inp.get("cmd") or "")
|
|
1025
|
+
return bool(command) and bool(UNEXPANDED.search(path)) and not known_tail(
|
|
1026
|
+
path, top, command)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
# A value that is the checkout itself: `R=$(git rev-parse --show-toplevel)`,
|
|
1030
|
+
# `R=$(pwd)`, `R=$PWD`.
|
|
1031
|
+
ROOT_VALUE = re.compile(
|
|
1032
|
+
r"^[\"']?(?:\$\(\s*(?:git\s+rev-parse\s+--show-toplevel|pwd)\s*\)"
|
|
1033
|
+
r"|`\s*(?:git\s+rev-parse\s+--show-toplevel|pwd)\s*`|\$\{?PWD\}?)[\"']?$")
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def known_tail(path, top, command=""):
|
|
1037
|
+
"""The checkout path a target names when it opens on a variable the same
|
|
1038
|
+
command line set to the checkout (`ROOT_VALUE`), or None. `R=$(git
|
|
1039
|
+
rev-parse --show-toplevel); printf x > $R/src/a.py` writes `src/a.py`, so the
|
|
1040
|
+
rule holds that file. Any other unknown variable - a `mktemp -d` directory,
|
|
1041
|
+
a loop variable, one an earlier call exported - leaves the target unnamed:
|
|
1042
|
+
re-rooting `$D/README.md` or `packages/$p/x` at the checkout refused writes
|
|
1043
|
+
that never touched it (review of plan 083, N1).
|
|
1044
|
+
ponytail: a root exported by an earlier call (`export R=$PWD`, then `> $R/x`)
|
|
1045
|
+
is not seen, so that write passes unnamed."""
|
|
1046
|
+
if not top:
|
|
1047
|
+
return None
|
|
1048
|
+
pieces = list(UNEXPANDED_PIECE.finditer(path))
|
|
1049
|
+
if len(pieces) != 1 or pieces[0].start() != 0:
|
|
1050
|
+
return None
|
|
1051
|
+
name = pieces[0].group(0).strip("${}")
|
|
1052
|
+
rest = path[pieces[0].end():].lstrip("/")
|
|
1053
|
+
if not rest or UNEXPANDED.search(rest):
|
|
1054
|
+
return None
|
|
1055
|
+
cut = command.find(path)
|
|
1056
|
+
head = command[:cut if cut >= 0 else len(command)]
|
|
1057
|
+
values = re.findall(r"(?:^|(?<=[\s;&|(]))%s=(\$\([^)]*\)|`[^`]*`|\"[^\"]*\"|\S+)"
|
|
1058
|
+
% re.escape(name), head)
|
|
1059
|
+
return (os.path.join(top, rest)
|
|
1060
|
+
if values and ROOT_VALUE.match(values[-1]) else None)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
# The front matter key that makes a plan the active task (tezgah_task.active).
|
|
1064
|
+
PLAN_PHASE_LINE = re.compile(r"(?m)^\s*\+?\s*phase\s*:\s*\S")
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def workspace_free(rel, inp):
|
|
1068
|
+
"""True when `rel` is tezgah's own workspace the plan does not scope: every
|
|
1069
|
+
`.tezgah/` path but the slice journal (`tezgah-task slice` writes it) and a
|
|
1070
|
+
plan file that could make itself the active task - a shell write into
|
|
1071
|
+
`.tezgah/plans/open/` (its text is unknown) or a write tool's text that
|
|
1072
|
+
carries a `phase:` line. plan-add's own plan file and README stay free.
|
|
1073
|
+
Compared casefolded: on a case-insensitive disk `.tezgah/PLANS/` is the
|
|
1074
|
+
plans directory (review of plan 083, N2 and N5)."""
|
|
1075
|
+
low = (rel or "").casefold()
|
|
1076
|
+
if not low.startswith(".tezgah/") or low.startswith(".tezgah/slices/"):
|
|
1077
|
+
return False
|
|
1078
|
+
if low.startswith(".tezgah/plans/open/"):
|
|
1079
|
+
if not isinstance(inp, dict) or inp.get("command") or inp.get("cmd"):
|
|
1080
|
+
return False
|
|
1081
|
+
return not any(isinstance(inp.get(k), str) and PLAN_PHASE_LINE.search(inp[k])
|
|
1082
|
+
for k in EDIT_TEXT)
|
|
1083
|
+
return True
|
|
953
1084
|
|
|
954
1085
|
|
|
955
1086
|
def race_reason(inp, session_id, cwd=None):
|
|
@@ -970,15 +1101,239 @@ def race_reason(inp, session_id, cwd=None):
|
|
|
970
1101
|
# this guard read the scheme as a relative path. Only the harness's own
|
|
971
1102
|
# channels are skipped - a `file://`, `s3://` or editor URI can name a
|
|
972
1103
|
# real target two sessions both write, and stays guarded.
|
|
973
|
-
|
|
1104
|
+
# a shell target whose `$VAR` has no known value names no file the
|
|
1105
|
+
# ledger could hold either (unnamed_target)
|
|
1106
|
+
if (URI_CHANNEL.match(path) or scratch_target(path, cwd)
|
|
1107
|
+
or (not URI_TARGET.match(path) and unnamed_target(path, inp))):
|
|
974
1108
|
continue # a temp file or a device is no one's shared work
|
|
975
|
-
others = writers_elsewhere(path, session_id, RACE_WINDOW_MIN,
|
|
1109
|
+
others = [w for w in writers_elsewhere(path, session_id, RACE_WINDOW_MIN,
|
|
1110
|
+
cwd=cwd, lineage=True)
|
|
1111
|
+
if not ended_child(w, session_id, path, cwd)]
|
|
976
1112
|
if others:
|
|
977
|
-
return RACE_DENY % (", ".join(
|
|
1113
|
+
return RACE_DENY % (", ".join(race_who(*w[:3]) for w in others[:3]),
|
|
978
1114
|
RACE_WINDOW_MIN, path)
|
|
979
1115
|
return None
|
|
980
1116
|
|
|
981
1117
|
|
|
1118
|
+
def race_who(stem, relation, agent):
|
|
1119
|
+
"""One writer as RACE_DENY names it (RACE_WHO)."""
|
|
1120
|
+
text = RACE_WHO.get(relation, RACE_WHO["unrelated"])
|
|
1121
|
+
return text % ((agent or "?", stem) if text.count("%s") == 2 else (stem,))
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def ended_child(writer, session_id, path, cwd):
|
|
1125
|
+
"""True when `writer` (a writers_elsewhere (stem, relation, agent, ts)) is
|
|
1126
|
+
this session's own subagent whose slice record closed after its newest
|
|
1127
|
+
write of this file: a parent taking over a child that stopped on a quota
|
|
1128
|
+
error is no race (research orchestrator-flow-audit S7, finding 3). The
|
|
1129
|
+
record is read after `pending` closes an ended omp child, from cwd's journal
|
|
1130
|
+
and the written file's. No record, one still open, or a write newer than the
|
|
1131
|
+
record's close is a live writer and stays refused. ponytail: the close time
|
|
1132
|
+
is stored to the second, so a write in the second the record closed counts
|
|
1133
|
+
as before the close."""
|
|
1134
|
+
import calendar
|
|
1135
|
+
stem, relation, agent, wrote = writer
|
|
1136
|
+
if relation != "child" or not agent or tezgah_task is None:
|
|
1137
|
+
return False
|
|
1138
|
+
repos = {r for r in (tezgah_task.main_repo(os.path.join(cwd or os.getcwd(), path)),
|
|
1139
|
+
tezgah_task.main_repo(cwd or os.getcwd())) if r}
|
|
1140
|
+
base = re.sub(r"-\d+$", "", agent)
|
|
1141
|
+
recs = []
|
|
1142
|
+
try:
|
|
1143
|
+
for repo in repos:
|
|
1144
|
+
tezgah_task.pending(repo) # closes an omp child whose transcript ended
|
|
1145
|
+
recs += [r for r in tezgah_task.slices(repo) if r.get("parent") == session_id
|
|
1146
|
+
and (r.get("name") or r.get("id")) in (agent, base)]
|
|
1147
|
+
closed = max(calendar.timegm(time.strptime(str(r.get("updated")), "%Y-%m-%dT%H:%M:%SZ"))
|
|
1148
|
+
for r in recs) if recs else None
|
|
1149
|
+
except Exception:
|
|
1150
|
+
return False
|
|
1151
|
+
return (closed is not None and closed >= int(wrote)
|
|
1152
|
+
and all(r.get("status") != "open" for r in recs))
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
# --- lease: a sibling slice owns this file (plan 086, S6) --------------------
|
|
1156
|
+
# What this closes: the race rule refuses only after the other slice wrote, and
|
|
1157
|
+
# 24 genuine sibling refusals in real transcripts were all in a shared place -
|
|
1158
|
+
# one worktree for 4 or 10 concurrent slices, a predecessor's tree, a shared
|
|
1159
|
+
# plan record (research orchestrator-flow-audit S6, findings 3 and 5). A brief's
|
|
1160
|
+
# `Owns:` line is a lease on the slice record (`tezgah_task.owned_globs`), and a
|
|
1161
|
+
# write by one slice into a path an open sibling of the same parent owns is
|
|
1162
|
+
# refused before any work is lost. The second refusal is the checkout itself:
|
|
1163
|
+
# a slice whose record names a worktree writing into the main checkout (plan
|
|
1164
|
+
# 086 wave 1: two slices did, by relative paths that resolved against the
|
|
1165
|
+
# parent's cwd). Refused, not noted: a notice arrives after the file landed in
|
|
1166
|
+
# the parent's tree, and the fix - the same write under the worktree - costs
|
|
1167
|
+
# one re-issue. `.tezgah/` (the plan record, research data) and scratch stay
|
|
1168
|
+
# free: they live in the main checkout by design.
|
|
1169
|
+
#
|
|
1170
|
+
# The writing session is mapped to its record two ways. omp: its ledger's
|
|
1171
|
+
# `spawned` row (`tezgah_integrity.spawn_of`: parent session and spawned name);
|
|
1172
|
+
# omp writes it for every task child, and the record stays open while the child
|
|
1173
|
+
# runs. Claude: a background child's calls carry the parent's session id and
|
|
1174
|
+
# their own `agent_id`, which the spawn's result named and the record keeps as
|
|
1175
|
+
# `child` (`hooks/projects-posttooluse.py`). Every other case fails open:
|
|
1176
|
+
# Claude's foreground agents, dsh, Cursor and opencode open the record only when
|
|
1177
|
+
# the synchronous spawn returns, so no sibling record is open while the child
|
|
1178
|
+
# writes, and Codex writes no `spawned` row. A slice may write a path its own
|
|
1179
|
+
# `Owns:` names even when a sibling's names it too: the overlap is the parent's
|
|
1180
|
+
# brief, and the race rule still stands between the two.
|
|
1181
|
+
LEASE_DENY = (
|
|
1182
|
+
"Lease: %s is owned by sibling slice %s (owns %s), still open under your "
|
|
1183
|
+
"parent. Leave the file to it: %s, or ask your parent to move the lease; "
|
|
1184
|
+
"once the owner has finished, `tezgah-task slice done %s` frees it. Every "
|
|
1185
|
+
"lease is on `tezgah-task slice board`.")
|
|
1186
|
+
MAIN_CHECKOUT_DENY = (
|
|
1187
|
+
"Lease: %s is in the main checkout %s, but your slice %s works in worktree "
|
|
1188
|
+
"%s. Write the same path under that worktree: an absolute path, or a shell "
|
|
1189
|
+
"whose `cwd` or `cd` is there - a relative path resolves against this "
|
|
1190
|
+
"session's cwd.")
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def _channel(rec):
|
|
1194
|
+
"""How a sibling reaches the slice `rec` on its host."""
|
|
1195
|
+
if rec.get("host") == "claude" and rec.get("child"):
|
|
1196
|
+
return "message the owner (`SendMessage` to %s)" % rec["child"]
|
|
1197
|
+
if rec.get("host") == "omp":
|
|
1198
|
+
return "message the owner (`write agent://%s`)" % (rec.get("name") or rec["id"])
|
|
1199
|
+
return "ask the owner through your parent"
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def _shell_dir(inp, cwd):
|
|
1203
|
+
"""The directory a shell call's redirect or `tee` target resolves against:
|
|
1204
|
+
the call's own `cwd`/`workdir`, then each `cd`/`pushd` before the first
|
|
1205
|
+
redirect or `tee` that writes a file, read the way `shell_control` walks
|
|
1206
|
+
the line. A redirect to `/dev/*`, a descriptor (`2>&1`) or `-` writes no
|
|
1207
|
+
file, so `pushd /wt >/dev/null && x > out` resolves `out` under `/wt`."""
|
|
1208
|
+
base = inp.get("cwd") or inp.get("workdir")
|
|
1209
|
+
where, words, pending = _full(base, cwd) if base else cwd, [], False
|
|
1210
|
+
for word, is_op in _shell_words(inp.get("command") or inp.get("cmd") or ""):
|
|
1211
|
+
if pending:
|
|
1212
|
+
pending = False
|
|
1213
|
+
if not is_op:
|
|
1214
|
+
target = _unquote(word)
|
|
1215
|
+
if not (target.startswith(("&", "/dev/")) or target.isdigit()
|
|
1216
|
+
or target == "-"):
|
|
1217
|
+
return where
|
|
1218
|
+
continue
|
|
1219
|
+
if is_op and ">" in word:
|
|
1220
|
+
if words and words[-1].isdigit():
|
|
1221
|
+
words.pop() # the descriptor before `2>`
|
|
1222
|
+
pending = True
|
|
1223
|
+
continue
|
|
1224
|
+
if not is_op:
|
|
1225
|
+
if _unquote(word) == "tee" and not words:
|
|
1226
|
+
return where
|
|
1227
|
+
words.append(_unquote(word))
|
|
1228
|
+
continue
|
|
1229
|
+
while words and words[0] in SHELL_KEYWORDS:
|
|
1230
|
+
words = words[1:]
|
|
1231
|
+
if words and words[0] in ("cd", "pushd"):
|
|
1232
|
+
dest = [a for a in words[1:] if not a.startswith("-")]
|
|
1233
|
+
where = _full(dest[0] if dest else "~", where)
|
|
1234
|
+
words = []
|
|
1235
|
+
return where
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def lease_reason(inp, session_id, cwd=None, agent=None):
|
|
1239
|
+
"""LEASE_DENY or MAIN_CHECKOUT_DENY for the first of this call's files
|
|
1240
|
+
that meets one, else None. `agent` is Claude's `agent_id`. Fails open: no
|
|
1241
|
+
`spawned` row and no `agent_id`, no open record of that slice, an
|
|
1242
|
+
unreadable journal."""
|
|
1243
|
+
if spawn_of is None or tezgah_task is None or not session_id:
|
|
1244
|
+
return None
|
|
1245
|
+
spawn = spawn_of(session_id) or ((session_id, agent) if agent else None)
|
|
1246
|
+
if not spawn:
|
|
1247
|
+
return None
|
|
1248
|
+
here = cwd or os.getcwd()
|
|
1249
|
+
try:
|
|
1250
|
+
if inp.get("command") or inp.get("cmd"):
|
|
1251
|
+
here = _shell_dir(inp, here)
|
|
1252
|
+
for path in write_paths(inp):
|
|
1253
|
+
if (URI_CHANNEL.match(path) or scratch_target(path, here)
|
|
1254
|
+
or unnamed_target(path, inp)):
|
|
1255
|
+
continue
|
|
1256
|
+
real = os.path.realpath(os.path.join(here, os.path.expanduser(path)))
|
|
1257
|
+
repo = tezgah_task.main_repo(real)
|
|
1258
|
+
me = tezgah_task.slice_of(repo, *spawn) if repo else None
|
|
1259
|
+
if not me or me.get("status") != "open":
|
|
1260
|
+
continue
|
|
1261
|
+
tree = me.get("declared") or ""
|
|
1262
|
+
top = linked_main(real) and tezgah_task._sibling_worktree(real, repo)
|
|
1263
|
+
rel = os.path.relpath(real, top or repo).replace(os.sep, "/")
|
|
1264
|
+
if rel.startswith((".tezgah/", "../")) or rel == ".tezgah":
|
|
1265
|
+
continue
|
|
1266
|
+
if not top and tree and tree != repo and os.path.isdir(tree):
|
|
1267
|
+
return MAIN_CHECKOUT_DENY % (rel, repo, me["id"], tree)
|
|
1268
|
+
if any(tezgah_task.match(rel, g) for g in me.get("owns") or ()):
|
|
1269
|
+
continue
|
|
1270
|
+
owner = tezgah_task.owner_of(repo, rel, spawn[0], me["id"])
|
|
1271
|
+
if owner:
|
|
1272
|
+
return LEASE_DENY % (rel, owner.get("name") or owner["id"],
|
|
1273
|
+
", ".join(owner["owns"][:3]), _channel(owner),
|
|
1274
|
+
owner["id"])
|
|
1275
|
+
except Exception:
|
|
1276
|
+
return None
|
|
1277
|
+
return None
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def spawn_advice(tool, inp, session_id, cwd=None):
|
|
1281
|
+
"""The shared-worktree warning for a spawn call (`tezgah_task.worktree_clash`),
|
|
1282
|
+
else None. A warning, never a deny: a fan-out of read-only reviewers on one
|
|
1283
|
+
checkout is fine, and the agent name is how this reads which ones are. Off
|
|
1284
|
+
under `orchestrate-off`, whose rule text it backs."""
|
|
1285
|
+
if (str(tool or "").lower() not in SUBAGENT_TOOLS or tezgah_task is None
|
|
1286
|
+
or not cwd or off("orchestrate-off")):
|
|
1287
|
+
return None
|
|
1288
|
+
try:
|
|
1289
|
+
root = tezgah_task.main_repo(cwd)
|
|
1290
|
+
return tezgah_task.worktree_clash(root, tool, inp, session_id or "") if root else None
|
|
1291
|
+
except Exception:
|
|
1292
|
+
return None
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
# The cross-project write notice: a session works under the rules of the
|
|
1296
|
+
# repository its cwd is in, so a write into another one is under none of that
|
|
1297
|
+
# repository's plans, lessons or slice journal (research orchestrator-flow-audit
|
|
1298
|
+
# S7, finding 7). A notice, never a refusal: the write may be the user's ask.
|
|
1299
|
+
CROSS_PROJECT = (
|
|
1300
|
+
"tezgah: this write is in %s, not in this session's repository (%s). That "
|
|
1301
|
+
"repository's plans, lessons and slice journal do not apply to this session. "
|
|
1302
|
+
"Work on that project from a session opened there.")
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def _main_repo(path):
|
|
1306
|
+
"""The main checkout of the repository holding `path`, else None
|
|
1307
|
+
(`tezgah_task.main_repo`: a repository's own `.tezgah` is that repository)."""
|
|
1308
|
+
return tezgah_task.main_repo(path) if tezgah_task is not None else None
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def cross_project_note(session_id, tool, inp, cwd):
|
|
1312
|
+
"""CROSS_PROJECT when this write call's file lies in another repository than
|
|
1313
|
+
cwd's (a worktree counts as its main checkout), once per session per target
|
|
1314
|
+
repository (a `nudged` mark), else None. Every host's post-tool channel
|
|
1315
|
+
carries it beside its other notices. Total: any failure is no notice."""
|
|
1316
|
+
try:
|
|
1317
|
+
name = str(tool or "").lower()
|
|
1318
|
+
if (name not in WRITE_TOOLS and name not in BASH_TOOLS) or not cwd \
|
|
1319
|
+
or not isinstance(inp, dict) or inp.get("command") == "view":
|
|
1320
|
+
return None
|
|
1321
|
+
here = _main_repo(cwd)
|
|
1322
|
+
for path in write_paths(inp):
|
|
1323
|
+
if URI_CHANNEL.match(path) or scratch_target(path, cwd):
|
|
1324
|
+
continue
|
|
1325
|
+
there = _main_repo(os.path.join(cwd, path))
|
|
1326
|
+
if not there or there == here:
|
|
1327
|
+
continue
|
|
1328
|
+
key = "cross:" + hashlib.sha1(("%s\0%s" % (session_id, there)).encode(
|
|
1329
|
+
"utf-8", "replace")).hexdigest()[:16]
|
|
1330
|
+
return CROSS_PROJECT % (there, here or cwd) \
|
|
1331
|
+
if tezgah_store.mark("nudged", key) else None
|
|
1332
|
+
except Exception:
|
|
1333
|
+
return None
|
|
1334
|
+
return None
|
|
1335
|
+
|
|
1336
|
+
|
|
982
1337
|
# --- task scope: the plan's own phase and path allowlist --------------------
|
|
983
1338
|
# What this closes: a session that has declared what it is working on - a plan
|
|
984
1339
|
# file under .tezgah/plans/open is active, so the work has a phase and the
|
|
@@ -1013,13 +1368,19 @@ TASK_PHASE_DENY = (
|
|
|
1013
1368
|
"the plan's Acceptance, spike and checkpoint are in order.")
|
|
1014
1369
|
TASK_SCOPE_DENY = (
|
|
1015
1370
|
"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.")
|
|
1371
|
+
"plan's work genuinely reaches it, widen the scope yourself (allow replaces "
|
|
1372
|
+
"the list, so this keeps the current globs): `%s --why \"<what in the plan "
|
|
1373
|
+
"reaches it>\"`; otherwise leave the file alone.")
|
|
1374
|
+
# a target outside the repository: an allow glob is repo-relative, so none reaches it
|
|
1375
|
+
TASK_SCOPE_OUTSIDE = (
|
|
1376
|
+
"Task gate: %s is outside the paths the active task %s allows (%s). It lies "
|
|
1377
|
+
"outside this repository, and an allow glob is relative to it, so no "
|
|
1378
|
+
"`tezgah-task allow` line reaches it; leave the file alone, or do the work "
|
|
1379
|
+
"under that repository's own task.")
|
|
1019
1380
|
TASK_RECORD_DENY = (
|
|
1020
|
-
"Task gate: %s is the active task's own record
|
|
1021
|
-
"
|
|
1022
|
-
"
|
|
1381
|
+
"Task gate: %s is the active task's own record; only `tezgah-task` writes it, checking the plan's "
|
|
1382
|
+
"Acceptance, spike and review on the way. The phase and allowlist: `tezgah-task phase|allow`; an "
|
|
1383
|
+
"Acceptance box: `tezgah-task tick NNN <n>`; State/Next: `tezgah-task note NNN state|next \"...\"`.")
|
|
1023
1384
|
# The shapes that make a shell command a write, for the phase rule below. The
|
|
1024
1385
|
# tools are not the only way to change a file, and E7b measured the other one:
|
|
1025
1386
|
# with `edit` and `write` refused twelve and six times, the armed arm wrote the
|
|
@@ -1127,18 +1488,42 @@ def task_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
|
1127
1488
|
task = _task(cwd, base, task)
|
|
1128
1489
|
if not task:
|
|
1129
1490
|
return None
|
|
1491
|
+
# The targets the plan scopes: not a URI or a shell target with no known
|
|
1492
|
+
# value (unnamed_target), and not tezgah's own gitignored workspace under
|
|
1493
|
+
# `.tezgah/` (research lines, lessons, briefs) - the active record there is
|
|
1494
|
+
# task_record_reason's, refused before this rule runs. A call whose every
|
|
1495
|
+
# target is one of those writes no project file, so the phase does not
|
|
1496
|
+
# refuse it either; a call with no target at all still meets the phase.
|
|
1497
|
+
paths = write_paths(inp)
|
|
1498
|
+
project = []
|
|
1499
|
+
top = tezgah_task.repo_root(cwd, base) if cwd else None
|
|
1500
|
+
command = str(inp.get("command") or inp.get("cmd") or "") if isinstance(inp, dict) else ""
|
|
1501
|
+
for spelled in paths:
|
|
1502
|
+
if unnamed_target(spelled, inp, top):
|
|
1503
|
+
continue
|
|
1504
|
+
path = (known_tail(spelled, top, command) or spelled) if command else spelled
|
|
1505
|
+
rel = tezgah_task.relative(path, cwd, base, worktrees=True)
|
|
1506
|
+
if rel is not None and workspace_free(rel, inp):
|
|
1507
|
+
continue
|
|
1508
|
+
project.append((spelled, path, rel))
|
|
1509
|
+
if paths and not project:
|
|
1510
|
+
return None
|
|
1130
1511
|
if task["phase"] not in tezgah_task.WRITE_PHASES:
|
|
1131
1512
|
return TASK_PHASE_DENY % (task["id"], task["phase"],
|
|
1132
1513
|
" or ".join(tezgah_task.WRITE_PHASES))
|
|
1133
1514
|
patterns = task["allowed_paths"]
|
|
1134
1515
|
if not patterns:
|
|
1135
1516
|
return None
|
|
1136
|
-
for path in
|
|
1137
|
-
if scratch_target(
|
|
1517
|
+
for spelled, path, rel in project:
|
|
1518
|
+
if scratch_target(spelled, cwd):
|
|
1138
1519
|
continue
|
|
1139
|
-
rel = tezgah_task.relative(path, cwd, base)
|
|
1140
1520
|
if rel is None or not any(tezgah_task.match(rel, p) for p in patterns):
|
|
1141
|
-
|
|
1521
|
+
# the call's own spelling: the agent has to recognize its argument
|
|
1522
|
+
if rel is None:
|
|
1523
|
+
return TASK_SCOPE_OUTSIDE % (spelled, task["id"], ", ".join(patterns))
|
|
1524
|
+
ready = " ".join(["tezgah-task allow"] + [shlex.quote(p) for p in
|
|
1525
|
+
list(patterns) + [rel]])
|
|
1526
|
+
return TASK_SCOPE_DENY % (spelled, task["id"], ", ".join(patterns), ready)
|
|
1142
1527
|
return None
|
|
1143
1528
|
|
|
1144
1529
|
|
|
@@ -1203,6 +1588,9 @@ def checkpoint_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
|
1203
1588
|
# a shell line that changes no file is not this rule's, the same read of
|
|
1204
1589
|
# the same shape table task_shell_reason makes
|
|
1205
1590
|
return None
|
|
1591
|
+
paths = write_paths(inp)
|
|
1592
|
+
if command is None and paths and all(URI_TARGET.match(p) for p in paths):
|
|
1593
|
+
return None # a tool device or a message changes no tree state
|
|
1206
1594
|
task = _task(cwd, base, task)
|
|
1207
1595
|
if not task or task["phase"] != "implementation":
|
|
1208
1596
|
return None
|
|
@@ -1276,6 +1664,18 @@ def workspace_reason(inp, cwd, base):
|
|
|
1276
1664
|
# or whose target is a positional argument (`cp`, `sed -i`), leaves a row with
|
|
1277
1665
|
# no `key` or no row at all - the gate cannot read the line it adds - so the
|
|
1278
1666
|
# context cannot label that line.
|
|
1667
|
+
#
|
|
1668
|
+
# History, whatever the turn read (plan 077, rsi-borrow-screen idea 2): a write
|
|
1669
|
+
# that rewrites or removes a ledger line leaves one `lesson_changed` row per such
|
|
1670
|
+
# line, keyed by the old line and carrying its text, so a standing constraint
|
|
1671
|
+
# that was edited away can be read back from the ledger. Still never a refusal.
|
|
1672
|
+
# ponytail: a shell write the gate cannot read the new text of (`sed -i`, an
|
|
1673
|
+
# `echo >` body) leaves no `lesson_changed` row, and a rewritten line carries no
|
|
1674
|
+
# `new_key`: the row contract (`tezgah_integrity.LEDGER_FIELDS`) has no field
|
|
1675
|
+
# for it, so the line's new key rides only its `lesson_tainted` row, if any.
|
|
1676
|
+
LESSON_CHANGED_MAX = 10
|
|
1677
|
+
|
|
1678
|
+
|
|
1279
1679
|
def lesson_taint(tool, inp, cwd, session_id, agent=None, shell_body=None):
|
|
1280
1680
|
"""Write one `lesson_tainted` row per lesson line this call leaves in a
|
|
1281
1681
|
ledger that the ledger did not hold, when the user turn already holds a row
|
|
@@ -1285,24 +1685,28 @@ def lesson_taint(tool, inp, cwd, session_id, agent=None, shell_body=None):
|
|
|
1285
1685
|
the call lands (`_after_text`), so a partial-line edit names the line it
|
|
1286
1686
|
leaves, not its fragment. The keyed rows also go to the ledger's repository
|
|
1287
1687
|
index (`tezgah_lessons.write_taint`), which the context labels lines from.
|
|
1688
|
+
In any turn, one `lesson_changed` row per ledger line the call drops - its
|
|
1689
|
+
old `key`, its old text in `detail` (redacted by `note`), the ledger in
|
|
1690
|
+
`target`: read off the post-write text, a patch's `-` lines, or a shell
|
|
1691
|
+
heredoc that overwrites the file; an append drops nothing.
|
|
1288
1692
|
Returns nothing: it is never a refusal."""
|
|
1289
|
-
if tezgah_lessons is None or
|
|
1693
|
+
if tezgah_lessons is None or not session_id:
|
|
1290
1694
|
return
|
|
1291
1695
|
t = str(tool or "").lower()
|
|
1292
1696
|
|
|
1293
1697
|
def norm(path):
|
|
1294
1698
|
return os.path.normpath(os.path.join(cwd, os.path.expanduser(path)))
|
|
1295
1699
|
|
|
1296
|
-
|
|
1297
|
-
|
|
1700
|
+
written = write_texts(inp if t in WRITE_TOOLS else shell_body)
|
|
1701
|
+
texts = {norm(path): new for path, _old, new in written}
|
|
1298
1702
|
targets = sorted(p for p in set(texts) | set(map(norm, write_paths(inp)))
|
|
1299
1703
|
if tezgah_lessons.is_ledger(p))
|
|
1300
1704
|
if not targets:
|
|
1301
1705
|
return
|
|
1302
1706
|
source = next((row["source"] for row in reversed(
|
|
1303
|
-
turn_rows(session_id, agent=agent)) if row.get("source")),
|
|
1304
|
-
|
|
1305
|
-
|
|
1707
|
+
turn_rows(session_id, agent=agent)) if row.get("source")),
|
|
1708
|
+
None) if turn_rows else None
|
|
1709
|
+
minus = {norm(path): old for path, old, _new in written}
|
|
1306
1710
|
key_of = tezgah_lessons.lesson_key
|
|
1307
1711
|
for path in targets:
|
|
1308
1712
|
try:
|
|
@@ -1310,23 +1714,68 @@ def lesson_taint(tool, inp, cwd, session_id, agent=None, shell_body=None):
|
|
|
1310
1714
|
current = fh.read()
|
|
1311
1715
|
except OSError:
|
|
1312
1716
|
current = ""
|
|
1313
|
-
|
|
1717
|
+
lines = tezgah_lessons.entries(current)
|
|
1718
|
+
have = set(map(key_of, lines))
|
|
1314
1719
|
new = texts.get(path)
|
|
1720
|
+
gone = ()
|
|
1315
1721
|
if t in WRITE_TOOLS and new is not None:
|
|
1316
1722
|
after = _after_text(inp, current)
|
|
1317
|
-
|
|
1723
|
+
if after is None and path in _patch_deletes(inp, norm):
|
|
1724
|
+
gone = list(dict.fromkeys(lines)) # a patch's Delete File
|
|
1725
|
+
elif after is None: # a patch: its `-` lines leave, its `+` lines land
|
|
1726
|
+
gone = _dropped(tezgah_lessons.entries(minus.get(path) or ""), new)
|
|
1727
|
+
else:
|
|
1728
|
+
new, gone = after, _dropped(lines, after)
|
|
1729
|
+
elif new is not None and not _shell_appends(inp.get("command")):
|
|
1730
|
+
gone = _dropped(lines, new)
|
|
1731
|
+
fields = dict(target=path, agent=agent, workspace=root_for(cwd),
|
|
1732
|
+
id=call_id(tool, inp))
|
|
1733
|
+
# a rewrite of the whole ledger would otherwise push the refusal rules'
|
|
1734
|
+
# fixed tails (the last verify, prior calls) out of their window
|
|
1735
|
+
for line in gone[:LESSON_CHANGED_MAX]:
|
|
1736
|
+
note(session_id, "lesson_changed", line, key=key_of(line), **fields)
|
|
1737
|
+
if len(gone) > LESSON_CHANGED_MAX:
|
|
1738
|
+
rest = len(gone) - LESSON_CHANGED_MAX
|
|
1739
|
+
note(session_id, "lesson_changed", "%d more lines changed" % rest,
|
|
1740
|
+
items=rest, **fields)
|
|
1741
|
+
if not source:
|
|
1742
|
+
continue
|
|
1318
1743
|
keys = [k for k in dict.fromkeys(map(key_of, tezgah_lessons.entries(
|
|
1319
1744
|
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
1745
|
for key in keys:
|
|
1323
|
-
note(session_id, "lesson_tainted", source, key=key,
|
|
1746
|
+
note(session_id, "lesson_tainted", source, key=key, source=source,
|
|
1747
|
+
**fields)
|
|
1324
1748
|
if keys != [None]:
|
|
1325
1749
|
write_taint(os.path.dirname(os.path.dirname(path)), have | set(keys),
|
|
1326
1750
|
[{"key": k, "source": source, "ts": int(time.time())}
|
|
1327
1751
|
for k in keys])
|
|
1328
1752
|
|
|
1329
1753
|
|
|
1754
|
+
PATCH_DELETE = re.compile(r"^\*\*\* Delete File: (.+?)\s*$", re.M)
|
|
1755
|
+
|
|
1756
|
+
|
|
1757
|
+
def _patch_deletes(inp, norm):
|
|
1758
|
+
"""The paths an apply_patch input deletes (`*** Delete File: <path>`), normed;
|
|
1759
|
+
read from the patch text itself, so a hunk of blank lines is not a delete."""
|
|
1760
|
+
text = "\n".join(v for v in (inp or {}).values() if isinstance(v, str))
|
|
1761
|
+
return {norm(p) for p in PATCH_DELETE.findall(text)}
|
|
1762
|
+
|
|
1763
|
+
|
|
1764
|
+
def _dropped(lines, after):
|
|
1765
|
+
"""The ledger `lines` the text `after` no longer holds, once each, in order."""
|
|
1766
|
+
keep = set(map(tezgah_lessons.lesson_key, tezgah_lessons.entries(after)))
|
|
1767
|
+
return [ln for ln in dict.fromkeys(lines)
|
|
1768
|
+
if tezgah_lessons.lesson_key(ln) not in keep]
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
|
+
def _shell_appends(command):
|
|
1772
|
+
"""Whether a shell write's body is added to its target (`>>`, `tee -a`) rather
|
|
1773
|
+
than replacing it; True when the target is not read (no row beats a false one)."""
|
|
1774
|
+
m = SHELL_TARGET.search(mask(str(command or "")))
|
|
1775
|
+
return not m or m.group(0).startswith(">>") or bool(
|
|
1776
|
+
re.search(r"\s-(?:[A-Za-z]*a[A-Za-z]*|-append)\s", m.group(0)))
|
|
1777
|
+
|
|
1778
|
+
|
|
1330
1779
|
# --- the shell's write body: three write-tool rules reached through a heredoc -
|
|
1331
1780
|
# What this closes (the route E7c measured): SKIP_TEST, ATTRIB_LINE and the
|
|
1332
1781
|
# prefixed-token credential scan (secret_edit) read the text a write tool lands,
|
|
@@ -1570,6 +2019,48 @@ def effectful(tool, inp):
|
|
|
1570
2019
|
return mcp_class(tool) is not None
|
|
1571
2020
|
|
|
1572
2021
|
|
|
2022
|
+
# --- ready: an idle `wait` while ready steps are open (plan 086, S1) ----------
|
|
2023
|
+
# omp's `wait` blocks the parent until a job settles. Measured over 15 omp
|
|
2024
|
+
# sessions, 68.3% of spawns were followed by `wait` with no other call between,
|
|
2025
|
+
# and at 86 of the 163 such waits that had a todo list two or more items were
|
|
2026
|
+
# open. The owner's default is a warning, never a deny: the line rides back with
|
|
2027
|
+
# the wait's result, as the graph line does (`graph_advice`).
|
|
2028
|
+
READY_WAIT = (
|
|
2029
|
+
"tezgah: idle wait - nothing ran since the spawn while %d ready todo "
|
|
2030
|
+
"step(s) are open: %s. Dispatch every ready step now, independent ones as "
|
|
2031
|
+
"subagents in one message, or mark it blocked on the slice it waits for. "
|
|
2032
|
+
"Ending the turn is the non-blocking alternative to `wait`: the host wakes "
|
|
2033
|
+
"you on the result.")
|
|
2034
|
+
|
|
2035
|
+
|
|
2036
|
+
def ready_advice(tool, session_id, cwd=None):
|
|
2037
|
+
"""The idle-wait warning for a `wait` call, else None: the newest spawn in
|
|
2038
|
+
the ledger tail is followed by no call but waits and todo updates, and the
|
|
2039
|
+
session's newest `todo` row (`tezgah_integrity.newest_todo`) holds a ready
|
|
2040
|
+
item no open slice of this session is working on
|
|
2041
|
+
(`tezgah_integrity.ready_heads`). A row with no `tool` (a turn marker, a
|
|
2042
|
+
`spawn` row) is no call. Off under `orchestrate-off`, whose rule text it
|
|
2043
|
+
backs."""
|
|
2044
|
+
if str(tool or "").lower() != "wait" or not session_id:
|
|
2045
|
+
return None
|
|
2046
|
+
if off("orchestrate-off"):
|
|
2047
|
+
return None
|
|
2048
|
+
rows = events(session_id, tail=DRIFT_TAIL)
|
|
2049
|
+
for row in reversed(rows):
|
|
2050
|
+
name = str(row.get("tool") or "").lower()
|
|
2051
|
+
if name in SUBAGENT_TOOLS:
|
|
2052
|
+
break
|
|
2053
|
+
if name and name != "wait" and name not in TODO_TOOLS:
|
|
2054
|
+
return None
|
|
2055
|
+
else:
|
|
2056
|
+
return None
|
|
2057
|
+
todo = newest_todo(reversed(rows))
|
|
2058
|
+
heads = ready_heads(todo, session_id, cwd) if todo else []
|
|
2059
|
+
if not heads:
|
|
2060
|
+
return None
|
|
2061
|
+
return READY_WAIT % (len(heads), "; ".join(cut(h, 48) for h in heads[:3]))
|
|
2062
|
+
|
|
2063
|
+
|
|
1573
2064
|
# --- plan required: work that spans files on main has no plan ----------------
|
|
1574
2065
|
# What this closes: a turn that wrote four product files - a hook, its tests, its
|
|
1575
2066
|
# docs and the changelog - straight onto `main`, with the plan opened afterwards.
|
|
@@ -2642,6 +3133,16 @@ def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
|
|
|
2642
3133
|
reason = race_reason(inp, session_id, cwd)
|
|
2643
3134
|
if reason:
|
|
2644
3135
|
return _deny(session_id, "race", reason, tool, inp, base)
|
|
3136
|
+
# Lease: a write into a file an open sibling slice owns, or into the main
|
|
3137
|
+
# checkout by a slice whose record names a worktree (see LEASE_DENY). After
|
|
3138
|
+
# the race rule, which names a write already lost; before the task scope,
|
|
3139
|
+
# since the allowlist is the plan's and the lease is one slice's share of it.
|
|
3140
|
+
# It backs the fan-out rule's text, so its switch is that rule's; with the
|
|
3141
|
+
# journal off there is no record to read.
|
|
3142
|
+
if writes and not off("orchestrate-off") and not off("slices-off"):
|
|
3143
|
+
reason = lease_reason(inp, session_id, cwd, agent)
|
|
3144
|
+
if reason:
|
|
3145
|
+
return _deny(session_id, "lease", reason, tool, inp, base)
|
|
2645
3146
|
# Task scope: the active plan's phase and the files its allowlist names (see
|
|
2646
3147
|
# task_reason). The record itself is refused first of all: only
|
|
2647
3148
|
# bin/tezgah-task writes it, through the checks it makes on the way.
|
|
@@ -2805,8 +3306,9 @@ def _dry_decision(tool, inp, cwd, session_id, agent=None):
|
|
|
2805
3306
|
"""`decision` with every write it makes swapped out for the call.
|
|
2806
3307
|
|
|
2807
3308
|
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,
|
|
3309
|
+
deny, drift, nudge, lesson_tainted and lesson_changed rows, `write_taint`
|
|
3310
|
+
for the lesson taint index, `first_nudge` for the once-per-session mark,
|
|
3311
|
+
`capture` for the
|
|
2810
3312
|
snapshot), so one swap covers every rule and a new rule
|
|
2811
3313
|
that records through them is dry here without being told. Safe because
|
|
2812
3314
|
the dry run is its own process (bin/tezgah-gate check): nothing else in it
|
|
@@ -2836,6 +3338,54 @@ def _dry_decision(tool, inp, cwd, session_id, agent=None):
|
|
|
2836
3338
|
DENY_RULES = {"control": None, "explorer": None, "shortcut": "verify-off",
|
|
2837
3339
|
"piped": "verify-off",
|
|
2838
3340
|
"attribution": None, "lang": "lang-off", "race": None,
|
|
3341
|
+
"lease": "orchestrate-off",
|
|
2839
3342
|
"task": "task-off", "workspace": "workspace-off", "secret": None,
|
|
2840
3343
|
"plan": None, "order": "verify-off", "loop": "verify-off",
|
|
2841
3344
|
"retry": "verify-off", "drift": "reminder-off"}
|
|
3345
|
+
|
|
3346
|
+
|
|
3347
|
+
# --- ask: an ask-tool question with no recommended option (plan 086, S3) -----
|
|
3348
|
+
# The ask policy's structured half: omp's `ask` and Claude's AskUserQuestion
|
|
3349
|
+
# carry their questions as data, so the check needs no word list for the
|
|
3350
|
+
# question itself. 7 of 51 distinct `ask` questions in the owner's omp
|
|
3351
|
+
# transcripts carried no `recommended` option (research orchestrator-flow-audit
|
|
3352
|
+
# S3, finding 4). A warning, never a deny (the owner's 2026-09-26 decision
|
|
3353
|
+
# removed the per-command consent gate): the line rides back with the call's
|
|
3354
|
+
# result, as the graph line does (`graph_advice`). Every such call leaves an
|
|
3355
|
+
# `ask` row, the counts `tezgah-gate replay` and the ledger read.
|
|
3356
|
+
ASK_NO_DEFAULT = (
|
|
3357
|
+
"tezgah: %d of %d question(s) in this %s call carry no "
|
|
3358
|
+
"recommended option. Each question put to the user names a recommended "
|
|
3359
|
+
"default (omp `ask`: `recommended`; AskUserQuestion: a \"({RECOMMENDED})\" "
|
|
3360
|
+
"option), and the work proceeds on it unless the step waits on the user's "
|
|
3361
|
+
"answer by nature (a force push, a delete of what the session did not "
|
|
3362
|
+
"make, production, a credential, a spend over the cap). ")
|
|
3363
|
+
ASK_GRANTED = (
|
|
3364
|
+
"tezgah: the user already granted %s this session; that step is taken, "
|
|
3365
|
+
"not asked again.")
|
|
3366
|
+
|
|
3367
|
+
|
|
3368
|
+
def ask_advice(tool, inp, session_id, cwd=None):
|
|
3369
|
+
"""The ask-policy warning for an ask-tool call, else None: a question with
|
|
3370
|
+
no recommended option (`tezgah_integrity.undefaulted`), or one about an
|
|
3371
|
+
outward step the user granted this session (`grants_in_force`). Records
|
|
3372
|
+
one `ask` row per call with the counts. Off under `verify-off`, whose Stop
|
|
3373
|
+
rule the policy's other half rides, and `pretooluse-off`."""
|
|
3374
|
+
from tezgah_integrity import (ASK_TOOLS, _said as said, grants_in_force,
|
|
3375
|
+
never_default, outward_classes, undefaulted)
|
|
3376
|
+
name = str(tool or "").lower()
|
|
3377
|
+
if name not in ASK_TOOLS or not session_id or not (cwd and root_for(cwd)) \
|
|
3378
|
+
or off("pretooluse-off") or off("verify-off"):
|
|
3379
|
+
return None
|
|
3380
|
+
asked, defaulted = undefaulted(inp)
|
|
3381
|
+
if not asked:
|
|
3382
|
+
return None
|
|
3383
|
+
qs = inp.get("questions") if isinstance(inp.get("questions"), list) else [inp]
|
|
3384
|
+
text = " ".join(str(q.get("question") or "") for q in qs if isinstance(q, dict))
|
|
3385
|
+
note(session_id, "ask", cut(" ".join(text.split()), 120), tool=str(tool),
|
|
3386
|
+
questions=asked, defaults=defaulted)
|
|
3387
|
+
out = ASK_NO_DEFAULT % (asked - defaulted, asked, tool) if defaulted < asked else ""
|
|
3388
|
+
granted = outward_classes(text) & grants_in_force(session_id)
|
|
3389
|
+
if granted and not never_default(text):
|
|
3390
|
+
out += ASK_GRANTED % ", ".join(sorted(granted))
|
|
3391
|
+
return said(out.strip()) if out else None
|