@r1z4x/tezgah 1.5.1 → 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 +126 -1
- package/MANIFEST +11 -0
- package/README.md +6 -3
- package/bin/consult +13 -10
- package/bin/tezgah-docs +18 -16
- package/bin/tezgah-doctor +152 -0
- package/bin/tezgah-research +286 -12
- package/bin/tezgah-route +48 -1
- package/bin/tezgah-setup +27 -14
- package/bin/tezgah-task +771 -46
- package/bin/tezgah-taste +48 -10
- package/docs/architecture.md +7 -4
- package/docs/citations-baseline.json +1 -1
- package/docs/clarity-baseline.json +5 -5
- package/docs/contract.md +112 -25
- package/docs/evidence.md +201 -28
- package/docs/feature-audit.md +25 -4
- package/docs/gate.md +70 -33
- package/docs/glossary.md +4 -4
- package/docs/hosts.md +8 -8
- package/docs/index.json +30 -10
- package/docs/judge.md +9 -3
- package/docs/models.md +35 -2
- package/docs/operations.md +103 -3
- package/docs/research.md +392 -13
- package/docs/skills.md +24 -6
- 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 -9
- package/hooks/tezgah_clarity.py +7 -3
- package/hooks/tezgah_components.py +2 -1
- package/hooks/tezgah_context.py +460 -390
- package/hooks/tezgah_gate.py +640 -104
- 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 +286 -130
- package/hooks/tezgah_product.py +107 -0
- package/hooks/tezgah_rank.py +29 -27
- package/hooks/tezgah_replay.py +200 -30
- package/hooks/tezgah_research.py +1297 -12
- package/hooks/tezgah_skill_pick.py +58 -17
- package/hooks/tezgah_stopspec.py +7 -0
- package/hooks/tezgah_store.py +74 -27
- package/hooks/tezgah_task.py +917 -8
- 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 +119 -39
- package/output-styles/tezgah.md +76 -42
- package/package.json +1 -1
- package/skills/analyze-app/SKILL.md +57 -0
- package/skills/design-contract/EVALS.md +24 -1
- package/skills/design-contract/SKILL.md +17 -1
- package/skills/design-contract/data-views.md +86 -0
- package/skills/feature-audit/SKILL.md +32 -3
- package/skills/i-have-adhd/EVALS.md +22 -2
- package/skills/i-have-adhd/SKILL.md +12 -5
- package/skills/plan-add/EVALS.md +8 -8
- package/skills/plan-add/SKILL.md +22 -21
- package/skills/plan-sync/EVALS.md +2 -2
- package/skills/plan-sync/SKILL.md +14 -8
- package/skills/pm-frameworks/SKILL.md +6 -0
- package/skills/ponytail/EVALS.md +21 -2
- package/skills/ponytail/SKILL.md +4 -4
- package/skills/product-analysis/EVALS.md +23 -1
- package/skills/product-analysis/SKILL.md +50 -11
- package/skills/product-flow/EVALS.md +81 -0
- package/skills/product-flow/SKILL.md +91 -0
- package/skills/research/SKILL.md +99 -2
- package/skills/tezgah-contract/EVALS.md +52 -7
- package/skills/tezgah-contract/SKILL.md +204 -85
package/hooks/tezgah_gate.py
CHANGED
|
@@ -38,28 +38,26 @@ Rules, all only inside a tezgah root:
|
|
|
38
38
|
8. a write while an active task is set - a plan file under .tezgah/plans/open whose
|
|
39
39
|
frontmatter carries a valid `phase` - is refused when that phase is one
|
|
40
40
|
that only reads, or when the file is outside the task's `allowed_paths`
|
|
41
|
-
refused (`task`). The record is
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
refused (`task`). The record is written by bin/tezgah-task, which the
|
|
42
|
+
orchestrating session runs itself as the work moves, so the refusal leans
|
|
43
|
+
on a state the CLI vetted (an Acceptance item per command, an answered
|
|
44
|
+
spike, a checkpoint) rather than one this gate inferred: no active task
|
|
45
|
+
means no requirement, and the rule invents nothing. The record also
|
|
46
|
+
carries where the phase returns to - a
|
|
45
47
|
`checkpoint:` sha, or `pending` while the tree still holds work no commit
|
|
46
48
|
names - and a write in `implementation` is refused while it reads `pending`
|
|
47
49
|
and the tree is dirty, so the phase's work starts from a named state rather
|
|
48
50
|
than on top of uncommitted changes; the per-file snapshots are not one
|
|
49
|
-
state to return to.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
measured open before they were closed - with the refusal naming the command
|
|
53
|
-
that lifts it, an armed arm removed or disabled the gate in 25 of 25 runs
|
|
54
|
-
and obeyed it in none (an internal benchmark, block E7) - and neither
|
|
55
|
-
refusal names a command now. The phase covers the shell too, because the
|
|
51
|
+
state to return to. A write whose target IS that record is refused too:
|
|
52
|
+
retyping the frontmatter would skip the preconditions the CLI checks. The
|
|
53
|
+
phase covers the shell too, because the
|
|
56
54
|
write tools are not the only way to change a file: with them refused, the
|
|
57
55
|
armed arm wrote the target with a heredoc redirect in 3 of 25 runs, so a
|
|
58
56
|
reading phase refuses a shell command that writes. A code-execution tool is
|
|
59
57
|
a third route the phase does not cover: the same block measured one row in
|
|
60
58
|
25 writing the target through `eval` after the write tools and the shell
|
|
61
59
|
were both refused, and that one is named rather than closed. The `task-off`
|
|
62
|
-
kill switch removes all
|
|
60
|
+
kill switch removes all three.
|
|
63
61
|
9. a `git commit` (or `--amend`) is refused while the newest check recorded in
|
|
64
62
|
this session failed (`order`): a commit is a claim that the tree passed, and
|
|
65
63
|
the state it would freeze is the one a check just rejected. The state is the
|
|
@@ -110,6 +108,7 @@ from tezgah_integrity import (BASH_TOOLS, BEGAN_KIND, ENV_WORD, GIT_VALUE_OPTS,
|
|
|
110
108
|
prior_calls, scratch_target, shortcut_command,
|
|
111
109
|
shortcut_edit, turn_rows, verify_command,
|
|
112
110
|
write_texts)
|
|
111
|
+
from tezgah_integrity import SUBAGENT_TOOLS, TODO_TOOLS, newest_todo, ready_heads
|
|
113
112
|
from tezgah_paths import (CACHE, CONFIG_DIR, HOST_DIRS, OFF_DIRS, PLUGIN_ROOT,
|
|
114
113
|
REPO_MARKS, SWITCHES, cache_dir, fallback_cache,
|
|
115
114
|
linked_main, off, root_for, roots)
|
|
@@ -126,9 +125,9 @@ except ImportError: # pragma: no cover - only on an integrity module without th
|
|
|
126
125
|
try: # The race rule reads the write history through tezgah_integrity; that
|
|
127
126
|
# reader is newer than some checkouts of the module, and a missing name must
|
|
128
127
|
# cost the rule, never the session (this import runs on every gated call).
|
|
129
|
-
from tezgah_integrity import writers_elsewhere
|
|
128
|
+
from tezgah_integrity import spawn_of, writers_elsewhere
|
|
130
129
|
except ImportError: # pragma: no cover - only on an integrity module without it
|
|
131
|
-
writers_elsewhere = None
|
|
130
|
+
spawn_of = writers_elsewhere = None
|
|
132
131
|
|
|
133
132
|
try: # The bytes a write is about to change. The snapshot module is newer than
|
|
134
133
|
# some checkouts too, and a missing module must cost the snapshot, never the
|
|
@@ -681,9 +680,10 @@ CLASS_NOTE = {
|
|
|
681
680
|
"transient": "it names a failure the host's own client may retry - a "
|
|
682
681
|
"timeout, a connection error, a rate limit, a 5xx - and an "
|
|
683
682
|
"identical repeat by the agent adds nothing to that",
|
|
684
|
-
"user": "it names something only the user can
|
|
685
|
-
"login, an access grant - so
|
|
686
|
-
"
|
|
683
|
+
"user": "it names something only the user can supply - a credential, a "
|
|
684
|
+
"login, an access grant - so take a route that needs none first "
|
|
685
|
+
"(another provider or member, a local run, a public source) and "
|
|
686
|
+
"ask the user once, naming what it unlocks, only when none is left",
|
|
687
687
|
"permanent": "an assertion or a bad argument does not change by re-running "
|
|
688
688
|
"it",
|
|
689
689
|
}
|
|
@@ -897,11 +897,23 @@ RACE_WINDOW_MIN = 10
|
|
|
897
897
|
# overwrite, so this rule cannot be a soft one.
|
|
898
898
|
RACE_REFUSE = True
|
|
899
899
|
RACE_DENY = (
|
|
900
|
-
"Concurrent write refused:
|
|
900
|
+
"Concurrent write refused: %s wrote this file within the last %d "
|
|
901
901
|
"minutes, so the copy of %s in your context may already be stale. Re-read "
|
|
902
902
|
"the file and re-apply your change to what is on disk now; if both sessions "
|
|
903
903
|
"are editing it, say so and let one of them own the file rather than "
|
|
904
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
|
+
}
|
|
905
917
|
# The file a write call names, per host dialect, plus the apply_patch headers
|
|
906
918
|
# (tezgah_integrity.PATCH_FILE) for the dialect whose paths live in the body.
|
|
907
919
|
# The race guard resolves each against the call's cwd to an absolute real path
|
|
@@ -912,8 +924,27 @@ RACE_DENY = (
|
|
|
912
924
|
# tool device, never a file on disk. Deliberately a list, not `scheme://`: a
|
|
913
925
|
# file reached through a URI scheme is still a file two sessions can race on.
|
|
914
926
|
URI_CHANNEL = re.compile(
|
|
915
|
-
r"^(?:agent|xd|local|artifact|proc|skill|mcp|omp|issue|pr|history|ssh|cfg
|
|
916
|
-
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_]*))")
|
|
917
948
|
WRITE_PATH = ("file_path", "filePath", "path", "notebook_path")
|
|
918
949
|
# The write-tool name a shell write's target is handed to `capture` under. capture
|
|
919
950
|
# takes a write tool's own path field and no shell tool name, and the row it
|
|
@@ -949,8 +980,107 @@ def write_paths(inp):
|
|
|
949
980
|
paths = [m.group(1) for m in PATCH_FILE.finditer(str(inp.get("patch") or ""))]
|
|
950
981
|
if paths:
|
|
951
982
|
return paths
|
|
952
|
-
|
|
953
|
-
|
|
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
|
|
954
1084
|
|
|
955
1085
|
|
|
956
1086
|
def race_reason(inp, session_id, cwd=None):
|
|
@@ -971,61 +1101,286 @@ def race_reason(inp, session_id, cwd=None):
|
|
|
971
1101
|
# this guard read the scheme as a relative path. Only the harness's own
|
|
972
1102
|
# channels are skipped - a `file://`, `s3://` or editor URI can name a
|
|
973
1103
|
# real target two sessions both write, and stays guarded.
|
|
974
|
-
|
|
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))):
|
|
975
1108
|
continue # a temp file or a device is no one's shared work
|
|
976
|
-
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)]
|
|
977
1112
|
if others:
|
|
978
|
-
return RACE_DENY % (", ".join(
|
|
1113
|
+
return RACE_DENY % (", ".join(race_who(*w[:3]) for w in others[:3]),
|
|
979
1114
|
RACE_WINDOW_MIN, path)
|
|
980
1115
|
return None
|
|
981
1116
|
|
|
982
1117
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
+
|
|
1337
|
+
# --- task scope: the plan's own phase and path allowlist --------------------
|
|
1338
|
+
# What this closes: a session that has declared what it is working on - a plan
|
|
1339
|
+
# file under .tezgah/plans/open is active, so the work has a phase and the
|
|
1340
|
+
# files it may touch - and then writes anyway. Two shapes cost real work: a
|
|
1341
|
+
# write in `discovery`, where the whole point of the phase is to read before
|
|
1342
|
+
# deciding, and a write outside the plan's own allowlist, which is how a
|
|
1343
|
+
# bounded task quietly becomes an unbounded one. The record is written by
|
|
1344
|
+
# bin/tezgah-task, which the orchestrating session runs itself: the plan's
|
|
1345
|
+
# lifecycle is the orchestrator's job, never a hand-off to the user. What keeps
|
|
1346
|
+
# the boundary is the CLI, not who runs it - a writing phase needs every
|
|
1347
|
+
# Acceptance item to name its proving command, `implementation` an answered
|
|
1348
|
+
# spike and a checkpoint, `close done` an approving review - so the record's own
|
|
1349
|
+
# file is refused to a write (TASK_RECORD_DENY): retyping the frontmatter is the
|
|
1350
|
+
# direct route that skips those checks. A shell line's positional target (`sed
|
|
1351
|
+
# -i`, `cp`) is not read here (see write_paths), the residual this rule names.
|
|
993
1352
|
#
|
|
994
1353
|
# The direction is fail-open wherever the record is missing, unreadable or out
|
|
995
|
-
# of phase vocabulary (tezgah_task.active returns None for those)
|
|
996
|
-
#
|
|
997
|
-
#
|
|
998
|
-
#
|
|
999
|
-
#
|
|
1000
|
-
#
|
|
1354
|
+
# of phase vocabulary (tezgah_task.active returns None for those): a rule that
|
|
1355
|
+
# refused every write until a record appeared would be the fail-closed trap the
|
|
1356
|
+
# design report names, with the whole session as its blast radius. An empty
|
|
1357
|
+
# allowlist is no scope declared rather than "nothing is allowed", so the phase
|
|
1358
|
+
# is the whole requirement there.
|
|
1359
|
+
#
|
|
1360
|
+
# The refusals name the CLI move, because running it is the work the rule asks
|
|
1361
|
+
# for and the CLI vets it. E7 measured a refusal that named a way to take the
|
|
1362
|
+
# rule off (the armed arm disabled the gate in 25 of 25 runs); the switches that
|
|
1363
|
+
# do that stay behind the control-plane rule below.
|
|
1001
1364
|
TASK_PHASE_DENY = (
|
|
1002
1365
|
"Task gate: the active task %s is in phase `%s`, and this call writes. "
|
|
1003
|
-
"Writes belong to %s.
|
|
1004
|
-
"
|
|
1366
|
+
"Writes belong to %s. Finish the reading this phase is for, then move the "
|
|
1367
|
+
"phase yourself with `tezgah-task phase implementation` - it refuses until "
|
|
1368
|
+
"the plan's Acceptance, spike and checkpoint are in order.")
|
|
1005
1369
|
TASK_SCOPE_DENY = (
|
|
1006
|
-
"Task gate: %s is outside the paths the active task %s allows (%s).
|
|
1007
|
-
"
|
|
1008
|
-
"why
|
|
1009
|
-
|
|
1010
|
-
#
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1370
|
+
"Task gate: %s is outside the paths the active task %s allows (%s). If the "
|
|
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.")
|
|
1014
1380
|
TASK_RECORD_DENY = (
|
|
1015
|
-
"Task gate: %s is the active task's own record
|
|
1016
|
-
"
|
|
1017
|
-
"
|
|
1018
|
-
TASK_UNLOCK_DENY = (
|
|
1019
|
-
"Task gate: this command changes the active task's own record. The phase and "
|
|
1020
|
-
"the allowlist are the user's, and a session that can move them is held to "
|
|
1021
|
-
"nothing. Ask the user to change the task instead.")
|
|
1022
|
-
# A shell command that changes the record. Matched on the masked text, so a
|
|
1023
|
-
# command that merely names the CLI - a grep over the docs, a commit message -
|
|
1024
|
-
# is not refused. The token is the CLI's own basename, with or without `.py`
|
|
1025
|
-
# and run through any interpreter, so any path to it counts.
|
|
1026
|
-
TASK_CHANGE = re.compile(
|
|
1027
|
-
r"(?:^|[|;&(]\s*|\s)(?:python3?\s+)?\S*tezgah-task(?:\.py)?\s+"
|
|
1028
|
-
r"(?:start|phase|allow|stop)\b")
|
|
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 \"...\"`.")
|
|
1029
1384
|
# The shapes that make a shell command a write, for the phase rule below. The
|
|
1030
1385
|
# tools are not the only way to change a file, and E7b measured the other one:
|
|
1031
1386
|
# with `edit` and `write` refused twelve and six times, the armed arm wrote the
|
|
@@ -1053,8 +1408,8 @@ SHELL_WRITE = re.compile(
|
|
|
1053
1408
|
TASK_SHELL_DENY = (
|
|
1054
1409
|
"Task gate: the active task %s is in phase `%s`, and this command writes a "
|
|
1055
1410
|
"file. The shell is a write route like any other, so the phase covers it. "
|
|
1056
|
-
"
|
|
1057
|
-
"
|
|
1411
|
+
"Finish the reading this phase is for, then move the phase yourself with "
|
|
1412
|
+
"`tezgah-task phase implementation`.")
|
|
1058
1413
|
|
|
1059
1414
|
|
|
1060
1415
|
# The marker a caller passes when it has NOT read the record, so a rule reads it
|
|
@@ -1100,11 +1455,10 @@ def task_shell_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
|
1100
1455
|
def task_record_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
1101
1456
|
"""A deny reason when this write's target IS the active task's record.
|
|
1102
1457
|
|
|
1103
|
-
The
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
scope, because a scope that can widen itself is not one."""
|
|
1458
|
+
The record is a file in the repo, so an agent that may write files could
|
|
1459
|
+
retype the phase instead of running the CLI, and skip the Acceptance, spike
|
|
1460
|
+
and review checks the CLI makes on the way. Refused whatever the phase and
|
|
1461
|
+
whatever the allowlist says - the record is not inside its own scope."""
|
|
1108
1462
|
task = _task(cwd, base, task)
|
|
1109
1463
|
if not task:
|
|
1110
1464
|
return None
|
|
@@ -1134,18 +1488,42 @@ def task_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
|
1134
1488
|
task = _task(cwd, base, task)
|
|
1135
1489
|
if not task:
|
|
1136
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
|
|
1137
1511
|
if task["phase"] not in tezgah_task.WRITE_PHASES:
|
|
1138
1512
|
return TASK_PHASE_DENY % (task["id"], task["phase"],
|
|
1139
1513
|
" or ".join(tezgah_task.WRITE_PHASES))
|
|
1140
1514
|
patterns = task["allowed_paths"]
|
|
1141
1515
|
if not patterns:
|
|
1142
1516
|
return None
|
|
1143
|
-
for path in
|
|
1144
|
-
if scratch_target(
|
|
1517
|
+
for spelled, path, rel in project:
|
|
1518
|
+
if scratch_target(spelled, cwd):
|
|
1145
1519
|
continue
|
|
1146
|
-
rel = tezgah_task.relative(path, cwd, base)
|
|
1147
1520
|
if rel is None or not any(tezgah_task.match(rel, p) for p in patterns):
|
|
1148
|
-
|
|
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)
|
|
1149
1527
|
return None
|
|
1150
1528
|
|
|
1151
1529
|
|
|
@@ -1182,11 +1560,9 @@ def task_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
|
1182
1560
|
# be read, and `task-off` above. A refusal has to rest on something a reader can
|
|
1183
1561
|
# check, and a rule that cannot answer must not refuse.
|
|
1184
1562
|
#
|
|
1185
|
-
# The refusal names the one command that makes the boundary real
|
|
1186
|
-
#
|
|
1187
|
-
#
|
|
1188
|
-
# command *satisfies* the requirement - running it is the work the rule asks for,
|
|
1189
|
-
# so naming it is naming what the rule wants rather than a way around it.
|
|
1563
|
+
# The refusal names the one command that makes the boundary real: running it is
|
|
1564
|
+
# the work the rule asks for, so naming it is naming what the rule wants rather
|
|
1565
|
+
# than a way around it.
|
|
1190
1566
|
CHECKPOINT_DENY = (
|
|
1191
1567
|
"Checkpoint gate: the active task %s is in phase `implementation` and its "
|
|
1192
1568
|
"plan records a pending checkpoint (`checkpoint: pending <sha>`), so this "
|
|
@@ -1212,6 +1588,9 @@ def checkpoint_reason(inp, cwd, base, task=_UNRESOLVED):
|
|
|
1212
1588
|
# a shell line that changes no file is not this rule's, the same read of
|
|
1213
1589
|
# the same shape table task_shell_reason makes
|
|
1214
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
|
|
1215
1594
|
task = _task(cwd, base, task)
|
|
1216
1595
|
if not task or task["phase"] != "implementation":
|
|
1217
1596
|
return None
|
|
@@ -1285,6 +1664,18 @@ def workspace_reason(inp, cwd, base):
|
|
|
1285
1664
|
# or whose target is a positional argument (`cp`, `sed -i`), leaves a row with
|
|
1286
1665
|
# no `key` or no row at all - the gate cannot read the line it adds - so the
|
|
1287
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
|
+
|
|
1288
1679
|
def lesson_taint(tool, inp, cwd, session_id, agent=None, shell_body=None):
|
|
1289
1680
|
"""Write one `lesson_tainted` row per lesson line this call leaves in a
|
|
1290
1681
|
ledger that the ledger did not hold, when the user turn already holds a row
|
|
@@ -1294,24 +1685,28 @@ def lesson_taint(tool, inp, cwd, session_id, agent=None, shell_body=None):
|
|
|
1294
1685
|
the call lands (`_after_text`), so a partial-line edit names the line it
|
|
1295
1686
|
leaves, not its fragment. The keyed rows also go to the ledger's repository
|
|
1296
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.
|
|
1297
1692
|
Returns nothing: it is never a refusal."""
|
|
1298
|
-
if tezgah_lessons is None or
|
|
1693
|
+
if tezgah_lessons is None or not session_id:
|
|
1299
1694
|
return
|
|
1300
1695
|
t = str(tool or "").lower()
|
|
1301
1696
|
|
|
1302
1697
|
def norm(path):
|
|
1303
1698
|
return os.path.normpath(os.path.join(cwd, os.path.expanduser(path)))
|
|
1304
1699
|
|
|
1305
|
-
|
|
1306
|
-
|
|
1700
|
+
written = write_texts(inp if t in WRITE_TOOLS else shell_body)
|
|
1701
|
+
texts = {norm(path): new for path, _old, new in written}
|
|
1307
1702
|
targets = sorted(p for p in set(texts) | set(map(norm, write_paths(inp)))
|
|
1308
1703
|
if tezgah_lessons.is_ledger(p))
|
|
1309
1704
|
if not targets:
|
|
1310
1705
|
return
|
|
1311
1706
|
source = next((row["source"] for row in reversed(
|
|
1312
|
-
turn_rows(session_id, agent=agent)) if row.get("source")),
|
|
1313
|
-
|
|
1314
|
-
|
|
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}
|
|
1315
1710
|
key_of = tezgah_lessons.lesson_key
|
|
1316
1711
|
for path in targets:
|
|
1317
1712
|
try:
|
|
@@ -1319,23 +1714,68 @@ def lesson_taint(tool, inp, cwd, session_id, agent=None, shell_body=None):
|
|
|
1319
1714
|
current = fh.read()
|
|
1320
1715
|
except OSError:
|
|
1321
1716
|
current = ""
|
|
1322
|
-
|
|
1717
|
+
lines = tezgah_lessons.entries(current)
|
|
1718
|
+
have = set(map(key_of, lines))
|
|
1323
1719
|
new = texts.get(path)
|
|
1720
|
+
gone = ()
|
|
1324
1721
|
if t in WRITE_TOOLS and new is not None:
|
|
1325
1722
|
after = _after_text(inp, current)
|
|
1326
|
-
|
|
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
|
|
1327
1743
|
keys = [k for k in dict.fromkeys(map(key_of, tezgah_lessons.entries(
|
|
1328
1744
|
new or ""))) if k not in have] or [None]
|
|
1329
|
-
fields = dict(source=source, target=path, agent=agent,
|
|
1330
|
-
workspace=root_for(cwd), id=call_id(tool, inp))
|
|
1331
1745
|
for key in keys:
|
|
1332
|
-
note(session_id, "lesson_tainted", source, key=key,
|
|
1746
|
+
note(session_id, "lesson_tainted", source, key=key, source=source,
|
|
1747
|
+
**fields)
|
|
1333
1748
|
if keys != [None]:
|
|
1334
1749
|
write_taint(os.path.dirname(os.path.dirname(path)), have | set(keys),
|
|
1335
1750
|
[{"key": k, "source": source, "ts": int(time.time())}
|
|
1336
1751
|
for k in keys])
|
|
1337
1752
|
|
|
1338
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
|
+
|
|
1339
1779
|
# --- the shell's write body: three write-tool rules reached through a heredoc -
|
|
1340
1780
|
# What this closes (the route E7c measured): SKIP_TEST, ATTRIB_LINE and the
|
|
1341
1781
|
# prefixed-token credential scan (secret_edit) read the text a write tool lands,
|
|
@@ -1579,6 +2019,48 @@ def effectful(tool, inp):
|
|
|
1579
2019
|
return mcp_class(tool) is not None
|
|
1580
2020
|
|
|
1581
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
|
+
|
|
1582
2064
|
# --- plan required: work that spans files on main has no plan ----------------
|
|
1583
2065
|
# What this closes: a turn that wrote four product files - a hook, its tests, its
|
|
1584
2066
|
# docs and the changelog - straight onto `main`, with the plan opened afterwards.
|
|
@@ -1823,8 +2305,8 @@ def _refusing(label):
|
|
|
1823
2305
|
# `tezgah-gate decide` writes a genuine `began` row with `check=1`; the capture
|
|
1824
2306
|
# CLI writes snapshot rows; pony and adhd write CONFIG_DIR; `tezgah-context
|
|
1825
2307
|
# attest` re-attests a session, and a clean result clears its drift mark. The
|
|
1826
|
-
# task CLI is not here:
|
|
1827
|
-
#
|
|
2308
|
+
# task CLI is not here: the orchestrating session runs it, and the checks that
|
|
2309
|
+
# matter live in the CLI itself (see the task rule above).
|
|
1828
2310
|
# `tezgah-context user_prompt` is a prompt event, and a prompt naming a switch
|
|
1829
2311
|
# writes the `authorized` row the switch latch honors (tezgah_paths.off).
|
|
1830
2312
|
CONTROL_CLIS = {"tezgah-gate": ("decide",), "tezgah-capture": "",
|
|
@@ -2651,11 +3133,19 @@ def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
|
|
|
2651
3133
|
reason = race_reason(inp, session_id, cwd)
|
|
2652
3134
|
if reason:
|
|
2653
3135
|
return _deny(session_id, "race", reason, tool, inp, base)
|
|
2654
|
-
#
|
|
2655
|
-
#
|
|
2656
|
-
#
|
|
2657
|
-
#
|
|
2658
|
-
#
|
|
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)
|
|
3146
|
+
# Task scope: the active plan's phase and the files its allowlist names (see
|
|
3147
|
+
# task_reason). The record itself is refused first of all: only
|
|
3148
|
+
# bin/tezgah-task writes it, through the checks it makes on the way.
|
|
2659
3149
|
if not off("task-off"):
|
|
2660
3150
|
# The record is read once and handed to every rule below: each read lists
|
|
2661
3151
|
# `.tezgah/plans/open/` and parses a plan, and this path is every write of
|
|
@@ -2667,9 +3157,6 @@ def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
|
|
|
2667
3157
|
reason = task_record_reason(inp, cwd, base, task)
|
|
2668
3158
|
if reason:
|
|
2669
3159
|
return _deny(session_id, "task", reason, tool, inp, base)
|
|
2670
|
-
if t in BASH_TOOLS and TASK_CHANGE.search(
|
|
2671
|
-
mask(str(inp.get("command") or ""))):
|
|
2672
|
-
return _deny(session_id, "task", TASK_UNLOCK_DENY, tool, inp, base)
|
|
2673
3160
|
if t in BASH_TOOLS:
|
|
2674
3161
|
reason = task_shell_reason(inp, cwd, base, task)
|
|
2675
3162
|
if reason:
|
|
@@ -2819,8 +3306,9 @@ def _dry_decision(tool, inp, cwd, session_id, agent=None):
|
|
|
2819
3306
|
"""`decision` with every write it makes swapped out for the call.
|
|
2820
3307
|
|
|
2821
3308
|
The writers are module names `decision` reaches through (`note` for the
|
|
2822
|
-
deny, drift, nudge and
|
|
2823
|
-
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
|
|
2824
3312
|
snapshot), so one swap covers every rule and a new rule
|
|
2825
3313
|
that records through them is dry here without being told. Safe because
|
|
2826
3314
|
the dry run is its own process (bin/tezgah-gate check): nothing else in it
|
|
@@ -2850,6 +3338,54 @@ def _dry_decision(tool, inp, cwd, session_id, agent=None):
|
|
|
2850
3338
|
DENY_RULES = {"control": None, "explorer": None, "shortcut": "verify-off",
|
|
2851
3339
|
"piped": "verify-off",
|
|
2852
3340
|
"attribution": None, "lang": "lang-off", "race": None,
|
|
3341
|
+
"lease": "orchestrate-off",
|
|
2853
3342
|
"task": "task-off", "workspace": "workspace-off", "secret": None,
|
|
2854
3343
|
"plan": None, "order": "verify-off", "loop": "verify-off",
|
|
2855
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
|