@r1z4x/tezgah 1.6.0 → 1.7.0

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