@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +126 -1
  2. package/MANIFEST +11 -0
  3. package/README.md +6 -3
  4. package/bin/consult +13 -10
  5. package/bin/tezgah-docs +18 -16
  6. package/bin/tezgah-doctor +152 -0
  7. package/bin/tezgah-research +286 -12
  8. package/bin/tezgah-route +48 -1
  9. package/bin/tezgah-setup +27 -14
  10. package/bin/tezgah-task +771 -46
  11. package/bin/tezgah-taste +48 -10
  12. package/docs/architecture.md +7 -4
  13. package/docs/citations-baseline.json +1 -1
  14. package/docs/clarity-baseline.json +5 -5
  15. package/docs/contract.md +112 -25
  16. package/docs/evidence.md +201 -28
  17. package/docs/feature-audit.md +25 -4
  18. package/docs/gate.md +70 -33
  19. package/docs/glossary.md +4 -4
  20. package/docs/hosts.md +8 -8
  21. package/docs/index.json +30 -10
  22. package/docs/judge.md +9 -3
  23. package/docs/models.md +35 -2
  24. package/docs/operations.md +103 -3
  25. package/docs/research.md +392 -13
  26. package/docs/skills.md +24 -6
  27. package/docs/status-line.md +5 -5
  28. package/docs/testing.md +2 -2
  29. package/docs/vision.md +12 -4
  30. package/hooks/hooks.json +2 -2
  31. package/hooks/lang/en.json +323 -0
  32. package/hooks/lang/tr.json +344 -0
  33. package/hooks/projects-posttooluse.py +24 -5
  34. package/hooks/projects-pretooluse.py +9 -4
  35. package/hooks/projects-stop.py +28 -16
  36. package/hooks/tezgah_agents.py +29 -9
  37. package/hooks/tezgah_clarity.py +7 -3
  38. package/hooks/tezgah_components.py +2 -1
  39. package/hooks/tezgah_context.py +460 -390
  40. package/hooks/tezgah_gate.py +640 -104
  41. package/hooks/tezgah_integrity.py +1480 -197
  42. package/hooks/tezgah_models.py +337 -15
  43. package/hooks/tezgah_paths.py +103 -10
  44. package/hooks/tezgah_policy.py +286 -130
  45. package/hooks/tezgah_product.py +107 -0
  46. package/hooks/tezgah_rank.py +29 -27
  47. package/hooks/tezgah_replay.py +200 -30
  48. package/hooks/tezgah_research.py +1297 -12
  49. package/hooks/tezgah_skill_pick.py +58 -17
  50. package/hooks/tezgah_stopspec.py +7 -0
  51. package/hooks/tezgah_store.py +74 -27
  52. package/hooks/tezgah_task.py +917 -8
  53. package/hosts/codex/hook.py +9 -10
  54. package/hosts/cursor/hook.py +21 -14
  55. package/hosts/dsh/hooks.json +2 -2
  56. package/hosts/omp/hook.py +44 -23
  57. package/hosts/omp/tezgah-hook.ts.in +93 -7
  58. package/hosts/opencode/plugins/tezgah.js +119 -39
  59. package/output-styles/tezgah.md +76 -42
  60. package/package.json +1 -1
  61. package/skills/analyze-app/SKILL.md +57 -0
  62. package/skills/design-contract/EVALS.md +24 -1
  63. package/skills/design-contract/SKILL.md +17 -1
  64. package/skills/design-contract/data-views.md +86 -0
  65. package/skills/feature-audit/SKILL.md +32 -3
  66. package/skills/i-have-adhd/EVALS.md +22 -2
  67. package/skills/i-have-adhd/SKILL.md +12 -5
  68. package/skills/plan-add/EVALS.md +8 -8
  69. package/skills/plan-add/SKILL.md +22 -21
  70. package/skills/plan-sync/EVALS.md +2 -2
  71. package/skills/plan-sync/SKILL.md +14 -8
  72. package/skills/pm-frameworks/SKILL.md +6 -0
  73. package/skills/ponytail/EVALS.md +21 -2
  74. package/skills/ponytail/SKILL.md +4 -4
  75. package/skills/product-analysis/EVALS.md +23 -1
  76. package/skills/product-analysis/SKILL.md +50 -11
  77. package/skills/product-flow/EVALS.md +81 -0
  78. package/skills/product-flow/SKILL.md +91 -0
  79. package/skills/research/SKILL.md +99 -2
  80. package/skills/tezgah-contract/EVALS.md +52 -7
  81. package/skills/tezgah-contract/SKILL.md +204 -85
@@ -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 the user's own, written by bin/tezgah-task and
42
- never by the agent, so the refusal leans on a decision the user made rather
43
- than one this gate inferred: no active task means no requirement, and the
44
- rule invents nothing. The record also carries where the phase returns to - a
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. Two further refusals close the ways a session could
50
- move the boundary instead of respecting it: a write whose target IS that
51
- record, and a shell command that would change it through the CLI. Both were
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 four.
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 fix - a credential, a "
685
- "login, an access grant - so stop and ask the user for it rather "
686
- "than change the approach",
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: session %s wrote this file within the last %d "
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
- target = shell_target(inp.get("command") or inp.get("cmd") or "")
953
- return [os.path.expanduser(os.path.expandvars(target))] if target else []
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
- if URI_CHANNEL.match(path) or scratch_target(path, cwd):
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, cwd=cwd)
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(str(s) for s in others[:3]),
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
- # --- task scope: the user's own phase and path allowlist --------------------
984
- # What this closes: a session that has been told what it is working on - the
985
- # user activated a plan file under .tezgah/plans/open, so the work has a phase and the
986
- # files it may touch - and then writes anyway. Two shapes cost the user real
987
- # work: a write in `discovery`, where the whole point of the phase is to read
988
- # before deciding, and a write outside the plan's own allowlist, which is how a
989
- # bounded task quietly becomes an unbounded one. Both are the user's own
990
- # decisions, which is why this rule may refuse at all: the record is written by
991
- # bin/tezgah-task, by hand, and never by the agent - a rule the agent could
992
- # satisfy by editing its own record would be a nudge wearing a gate's clothes.
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), because a
996
- # refusal has to rest on an ask the user actually made: a rule that refused
997
- # every write until a record appeared would be the fail-closed trap the design
998
- # report names, with the whole session as its blast radius. Nothing is named
999
- # that the user did not name either: an empty allowlist is no scope asked for
1000
- # rather than "nothing is allowed", so the phase is the whole requirement there.
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. The phase is the user's to move: ask them, or do the "
1004
- "reading this phase asks for and say what the write was for.")
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). The "
1007
- "allowlist is the user's to widen: ask them, or leave the file alone and say "
1008
- "why it looked in scope.")
1009
- # The two refusals above deliberately name no command. They used to end with the
1010
- # one that lifts them, and the E7 block measured what that costs: with the unlock
1011
- # printed in the refusal, the armed arm removed or disabled the gate in 25 of 25
1012
- # runs and obeyed it in none (an internal benchmark, block E7). A refusal
1013
- # is a boundary or it is an instruction; it cannot be both.
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. The phase and the allowlist "
1016
- "live in that file, so a session that edits it is editing the boundary it is "
1017
- "being held to. Ask the user to change the task; the record is theirs.")
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
- "The phase is the user's to move: ask them, or do the reading this phase "
1057
- "asks for and say what the write was for.")
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 other route to the same hole as TASK_UNLOCK_DENY, and the one a write
1104
- tool takes: the record is a file in the repo, so an agent that may write
1105
- files can retype the phase instead of running the CLI. Refused whatever the
1106
- phase and whatever the allowlist says - the record is not inside its own
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 write_paths(inp):
1144
- if scratch_target(path, cwd):
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
- return TASK_SCOPE_DENY % (path, task["id"], ", ".join(patterns))
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. That is not the
1186
- # mistake TASK_PHASE_DENY records: the E7 lesson is a refusal that names how to
1187
- # take the constraint off (the phase is the user's to move), while the checkpoint
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 turn_rows is None or not session_id:
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
- texts = {norm(path): new for path, _old, new in
1306
- write_texts(inp if t in WRITE_TOOLS else shell_body)}
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")), None)
1313
- if not source:
1314
- return
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
- have = set(map(key_of, tezgah_lessons.entries(current)))
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
- new = new if after is None else after # None: a patch's `+` lines
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, **fields)
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: it is the task rule's (TASK_CHANGE), which `task-off`
1827
- # removes, and that switch is one of the files this rule protects.
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
- # Task scope: the user's own record for this work - its phase and the files
2655
- # its allowlist names (see task_reason). The
2656
- # record itself is refused first of all, and a shell that would change it
2657
- # through the CLI is refused with it: a boundary the agent can move is not a
2658
- # boundary, and both routes to moving this one were measured open (E7).
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 lesson_tainted rows, `write_taint` for the lesson
2823
- taint index, `first_nudge` for the once-per-session mark, `capture` for the
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