@r1z4x/tezgah 1.11.0 → 1.13.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 (60) hide show
  1. package/CHANGELOG.md +333 -1
  2. package/MANIFEST +22 -0
  3. package/README.md +4 -3
  4. package/bin/tezgah-docs +1 -1
  5. package/bin/tezgah-research +39 -4
  6. package/bin/tezgah-setup +1229 -22
  7. package/bin/tezgah-task +55 -17
  8. package/docs/citations-baseline.json +1 -1
  9. package/docs/clarity-baseline.json +9 -3
  10. package/docs/contract.md +6 -3
  11. package/docs/environment.md +3 -2
  12. package/docs/evidence.md +81 -8
  13. package/docs/gate.md +6 -2
  14. package/docs/glossary.md +1 -1
  15. package/docs/hosts.md +297 -15
  16. package/docs/layers.md +1 -1
  17. package/docs/models.md +6 -0
  18. package/docs/operations.md +16 -5
  19. package/docs/research.md +36 -10
  20. package/docs/status-line.md +6 -0
  21. package/docs/testing.md +3 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/lang/en.json +11 -0
  24. package/hooks/lang/tr.json +11 -0
  25. package/hooks/projects-stop.py +2 -1
  26. package/hooks/tezgah_agents.py +27 -3
  27. package/hooks/tezgah_attest.py +79 -10
  28. package/hooks/tezgah_context.py +21 -8
  29. package/hooks/tezgah_gate.py +79 -23
  30. package/hooks/tezgah_integrity.py +212 -38
  31. package/hooks/tezgah_paths.py +43 -2
  32. package/hooks/tezgah_policy.py +35 -13
  33. package/hooks/tezgah_replay.py +8 -2
  34. package/hooks/tezgah_research.py +167 -24
  35. package/hooks/tezgah_stack.py +236 -1
  36. package/hooks/tezgah_task.py +2 -1
  37. package/hosts/amp/hook.py +217 -0
  38. package/hosts/amp/tezgah-plugin.ts.in +386 -0
  39. package/hosts/antigravity/hook.py +255 -0
  40. package/hosts/codex/hook.py +2 -1
  41. package/hosts/commandcode/hook.py +239 -0
  42. package/hosts/copilot/hook.py +284 -0
  43. package/hosts/cursor/hook.py +2 -1
  44. package/hosts/droid/hook.py +259 -0
  45. package/hosts/dsh/hooks.json +1 -1
  46. package/hosts/gemini/hook.py +213 -0
  47. package/hosts/goose/hook.py +145 -0
  48. package/hosts/omp/hook.py +4 -1
  49. package/hosts/omp/tezgah-hook.ts.in +30 -7
  50. package/hosts/opencode/plugins/tezgah.js +2 -2
  51. package/hosts/qwen/hook.py +206 -0
  52. package/output-styles/tezgah.md +10 -5
  53. package/package.json +1 -1
  54. package/skills/harness/SKILL.md +2 -0
  55. package/skills/i-have-adhd/SKILL.md +10 -1
  56. package/skills/plan-add/SKILL.md +5 -1
  57. package/skills/research/SKILL.md +22 -9
  58. package/skills/tezgah-contract/SKILL.md +19 -6
  59. package/statusline.py +20 -9
  60. package/workflows/graph-review.js +14 -2
@@ -114,7 +114,7 @@ from tezgah_integrity import (BASH_TOOLS, BEGAN_KIND, ENV_WORD, GIT_VALUE_OPTS,
114
114
  write_texts)
115
115
  from tezgah_integrity import (SUBAGENT_TOOLS, TODO_TOOLS, file_url_path, newest_todo,
116
116
  ready_heads)
117
- from tezgah_paths import (CACHE, CONFIG_DIR, HOST_DIRS, OFF_DIRS, PLUGIN_ROOT,
117
+ from tezgah_paths import (AMP_PLUGIN, CACHE, CONFIG_DIR, GOOSE_PLUGIN, HOST_DIRS, OFF_DIRS, PLUGIN_ROOT,
118
118
  REPO_MARKS, SWITCHES, _toplevel, cache_dir, fallback_cache,
119
119
  linked_main, off, root_for, roots)
120
120
 
@@ -193,11 +193,11 @@ PATTERN_SEARCH_TOOLS = ("grep", "search", "ast_grep")
193
193
  BASH_SEARCH = re.compile(r"(?:^|[|;&(]\s*|\s)(?:grep|rg)\s+((?:-\S+\s+)*)(\S+)")
194
194
  # the credit forms, not a bare tool name: "OpenAI" or "Claude Code" in a
195
195
  # sentence (naming a tool to use it) is fine, "Co-Authored-By: ..." or a
196
- # generator named as the author (signing it) is the thing to stop. The "... by
197
- # ..." verbs credit only when their object names the machine or a model: a
198
- # report generated by the build script, or an index built by the gate, is prose,
199
- # while AI / an LLM / GPT / Claude / ... as that object is a signature, so each
200
- # verb is tied to the name it credits. `_CREDIT` is shared with the anchored
196
+ # generator named as the author (signing it) is the thing to stop. A verb with
197
+ # "by"/"with"/"using" credits only when its object names the machine or a model:
198
+ # a report generated by the build script, or a note drafted with the owner, is
199
+ # prose, while AI / an LLM / GPT / Claude / ... as that object is a signature, so
200
+ # each verb is tied to the name it credits. `_CREDIT` is shared with the anchored
201
201
  # twin below so the two forms cannot drift apart.
202
202
  #
203
203
  # The cost of the model-object branch is deliberate and it falls on a message
@@ -206,12 +206,26 @@ BASH_SEARCH = re.compile(r"(?:^|[|;&(]\s*|\s)(?:grep|rg)\s+((?:-\S+\s+)*)(\S+)")
206
206
  # path pays nothing for it - `ATTRIB_LINE` anchors, so documentation that merely
207
207
  # names a form mid-sentence lands - and splitting the two sets apart was tried
208
208
  # and reverted, because the anchored half then missed `-m "Built by GPT"`.
209
+ # A `with`/`using` credit is a signature only when the name ends its clause
210
+ # ("Drafted with Claude", "Written using Claude Code.", "Generated using GPT-4,
211
+ # reviewed by me", "Written with Claude (Opus)"): "built with cursor support" or
212
+ # "client built using the OpenAI SDK" is prose, so that form takes a name that
213
+ # a quote, a comma, a dash or the line's end closes, and never the word `cursor`
214
+ # (review of plan 137). `by` keeps every name: "built by Cursor" is a credit.
215
+ # ponytail: "Drafted with Cursor" passes, and "built using OpenAI, Anthropic and
216
+ # others" is denied once; the deny names the reason, so one rephrase clears it.
209
217
  _CREDIT = (
210
218
  r"co-authored-by\s*:|generated with|made with|"
211
- r"(?:generated|written|authored|built|assisted)\s+by\s+"
212
- r"(?:(?:an?|the)\s+)?"
219
+ r"(?:generated|written|authored|built|assisted|drafted|produced|composed)"
220
+ r"\s+by\s+(?:(?:an?|the)\s+)?"
213
221
  r"(?:ai|llm|claude|chatgpt|gpt|copilot|codex|cursor|gemini|deepseek|"
214
- r"openai|anthropic)\b|noreply@anthropic|\U0001F916")
222
+ r"openai|anthropic)\b|"
223
+ r"(?:generated|written|authored|built|assisted|drafted|produced|composed)"
224
+ r"\s+(?:with|using)\s+(?:(?:an?|the)\s+)?"
225
+ r"(?:ai|llm|claude|chatgpt|gpt|copilot|codex|gemini|deepseek|openai|anthropic)"
226
+ r"(?:-[\w.]+)?(?:\s+(?:code|cli))?(?:\s*\([^)\n]*\))?"
227
+ r"(?=[.!)\]*_`]*(?:[\x22\x27,;]|\s+-\s|\s*(?:\n|$)))|"
228
+ r"noreply@anthropic|\U0001F916")
215
229
  ATTRIB = re.compile(_CREDIT, re.I)
216
230
  # The same forms anchored to the start of a line, for the text a write/edit tool
217
231
  # is about to land. A credit is a signature: it owns its line, bare or behind a
@@ -2014,6 +2028,37 @@ def workspace_reason(inp, cwd, base):
2014
2028
  return None
2015
2029
 
2016
2030
 
2031
+ # What this closes (plan 136): `tezgah-research init` refused a line while
2032
+ # another session's line was in flight, and the session ran its whole line under
2033
+ # `.tezgah/research/.staging/<slug>/` - a dot dir `slugs()` skips, so `status`
2034
+ # and `check` never saw it. A dot dir directly under research/ is refused; the
2035
+ # line layouts (`open/`, `done/`) and dot files beside them (`.gitkeep`) are not.
2036
+ # Read against the main checkout (a linked worktree's session writes the main
2037
+ # checkout's `.tezgah`) and against the session's own root.
2038
+ RESEARCH_HIDDEN_DENY = (
2039
+ "Workspace gate: %s is under a hidden dir. A research line lives under "
2040
+ ".tezgah/research/open or done; a hidden dir there is invisible to status and "
2041
+ "check. When `~/.config/tezgah/bin/tezgah-research` refuses a line, that "
2042
+ "refusal goes to the owner - never a way around it.")
2043
+
2044
+
2045
+ def research_hidden_reason(inp, cwd, base):
2046
+ """A deny reason when this write lands under `.tezgah/research/.<name>/` or
2047
+ `.tezgah/research/open|done/.<name>/` (what `stray_dirs` reports), else None."""
2048
+ if tezgah_task is None:
2049
+ return None
2050
+ for path in write_paths(inp):
2051
+ for rel in (tezgah_task.main_relative(path, cwd, base),
2052
+ tezgah_task.relative(path, cwd, base)):
2053
+ parts = (rel or "").casefold().split("/")
2054
+ if len(parts) > 3 and parts[:2] == [".tezgah", "research"] \
2055
+ and (parts[2].startswith(".") or (
2056
+ len(parts) > 4 and parts[2] in ("open", "done")
2057
+ and parts[3].startswith("."))):
2058
+ return RESEARCH_HIDDEN_DENY % path
2059
+ return None
2060
+
2061
+
2017
2062
  # --- lesson lineage: a ledger write in a turn that read untrusted text -------
2018
2063
  # What this records (security-08): any session can append a standing constraint
2019
2064
  # to `.tezgah/lessons.md`, and a line written in a turn that read a web page, an
@@ -2755,8 +2800,9 @@ CONTROL_CLIS = {"tezgah-gate": ("decide",), "tezgah-capture": "",
2755
2800
  # under `hosts/<host>/` (HOOK_HOSTS); its launchers carry their own names.
2756
2801
  HOOK_ENTRIES = frozenset(("projects-auto-init", "projects-pretooluse",
2757
2802
  "projects-posttooluse", "projects-stop",
2758
- "tezgah-codex-hook", "tezgah-cursor-hook"))
2759
- HOOK_HOSTS = frozenset(("codex", "cursor", "omp"))
2803
+ "tezgah-codex-hook", "tezgah-cursor-hook", "tezgah-qwen-hook",
2804
+ "tezgah-droid-hook", "tezgah-commandcode-hook", "tezgah-copilot-hook", "tezgah-gemini-hook", "tezgah-antigravity-hook", "tezgah-goose-hook"))
2805
+ HOOK_HOSTS = frozenset(("codex", "cursor", "omp", "qwen", "droid", "commandcode", "copilot", "gemini", "antigravity", "goose", "amp"))
2760
2806
  # Programs that change a path named in their arguments. Every positional is a
2761
2807
  # target, except for the copiers, whose last one is (`cp switch /tmp` reads it).
2762
2808
  # `sed`/`perl` count only with an in-place flag. `sqlite3` rewrites the cache
@@ -2797,9 +2843,9 @@ CONTROL_CACHE = ("evidence", "tezgah.db", "tezgah.db-wal", "tezgah.db-shm", "ses
2797
2843
  "switches", "gate-inactive", "harness-drift", "import-crash",
2798
2844
  "workspace-index.json")
2799
2845
  # Top-level keys tezgah writes into Claude's settings (bin/tezgah-setup
2800
- # wire_claude_statusline, wire_claude_attribution), and the one that turns every
2801
- # hook off at once; any other key or entry is tezgah's only when it names tezgah.
2802
- CLAUDE_OWNED = ("statusLine", "attribution", "disableAllHooks")
2846
+ # wire_claude_statusline, wire_claude_attribution) and the ones that turn hooks off: Claude's
2847
+ # `disableAllHooks`, Gemini's `hooksConfig` (Goose's `disabledPlugins` is owned per tezgah entry).
2848
+ OWNED_KEYS = ("statusLine", "attribution", "disableAllHooks", "hooksConfig")
2803
2849
  PATCH_DELETE = re.compile(r"(?m)^\*\*\* Delete File: (\S.*?)\s*$")
2804
2850
 
2805
2851
 
@@ -2884,6 +2930,11 @@ def _registration(real):
2884
2930
  shared = (os.path.join(HOST_DIRS["codex"], "hooks.json"),
2885
2931
  os.path.join(HOST_DIRS["codex"], "config.toml"),
2886
2932
  os.path.join(HOST_DIRS["cursor"], "hooks.json"),
2933
+ os.path.join(HOST_DIRS["qwen"], "settings.json"),
2934
+ # Droid reads hooks.json, else the `hooks` key of its settings.json
2935
+ os.path.join(HOST_DIRS["droid"], "hooks.json"),
2936
+ os.path.join(HOST_DIRS["droid"], "settings.json"),
2937
+ os.path.join(HOST_DIRS["commandcode"], "settings.json"), os.path.join(HOST_DIRS["copilot"], "settings.json"), os.path.join(HOST_DIRS["gemini"], "settings.json"), os.path.join(HOST_DIRS["antigravity"], "settings.json"), os.path.join(os.path.dirname(HOST_DIRS["antigravity"]), "config", "hooks.json"), os.path.join(HOST_DIRS["goose"], "settings.json"),
2887
2938
  os.path.join(claude, "plugins", "installed_plugins.json"),
2888
2939
  os.path.join(claude, "plugins", "known_marketplaces.json"))
2889
2940
  if (name in ("settings.json", "settings.local.json")
@@ -2894,8 +2945,8 @@ def _registration(real):
2894
2945
  return "shared"
2895
2946
  owned = [os.path.join(HOST_DIRS["opencode"], d, "tezgah.js")
2896
2947
  for d in ("plugins", "plugin")]
2897
- owned.append(os.path.join(HOST_DIRS["omp"], "agent", "hooks", "pre",
2898
- "tezgah-hook.ts"))
2948
+ owned += [os.path.join(HOST_DIRS["omp"], "agent", "hooks", "pre", "tezgah-hook.ts"),
2949
+ os.path.join(HOST_DIRS["copilot"], "hooks", "tezgah.json")] + [os.path.join(HOST_DIRS["gemini"], "extensions", "tezgah", *p) for p in (("gemini-extension.json",), ("hooks", "hooks.json"))] + [os.path.join(GOOSE_PLUGIN, *p) for p in (("plugin.json",), ("hooks", "hooks.json"))] + [AMP_PLUGIN]
2899
2950
  cache = os.path.realpath(os.path.join(claude, "plugins", "cache"))
2900
2951
  if real in {_real(p, None) for p in owned} or (
2901
2952
  _under(real, cache) and "tezgah" in real[len(cache):].lower()):
@@ -2988,14 +3039,14 @@ def _project(node):
2988
3039
 
2989
3040
  def _owned(node, path=()):
2990
3041
  """The tezgah-owned entries of a parsed JSON document, position-free: the
2991
- CLAUDE_OWNED top-level keys, any key naming tezgah, and each list item that
3042
+ OWNED_KEYS top-level keys, any key naming tezgah, and each list item that
2992
3043
  names tezgah, projected (`_project`)."""
2993
3044
  def dump(value):
2994
3045
  return json.dumps(value, sort_keys=True)
2995
3046
  if isinstance(node, dict):
2996
3047
  out = []
2997
3048
  for key, value in node.items():
2998
- if (not path and key in CLAUDE_OWNED) or "tezgah" in key.lower():
3049
+ if (not path and key in OWNED_KEYS) or "tezgah" in key.lower():
2999
3050
  out.append((path + (key,), dump(value)))
3000
3051
  else:
3001
3052
  out += _owned(value, path + (key,))
@@ -3584,8 +3635,11 @@ def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
3584
3635
  check=1 if t in BASH_TOOLS and verify_command(command, cwd) else None)
3585
3636
  # Claude's Task names the agent `subagent_type`; omp's task names it `agent`,
3586
3637
  # on the call (flat shape) or on each `tasks[]` item (batch shape).
3587
- subs = [inp.get("subagent_type"),
3588
- (inp.get("args") or {}).get("subagent_type"), inp.get("agent")]
3638
+ # A shell tool's `args` can be a list (Command Code splits argv there): only
3639
+ # a dict carries a subagent type, and a list must not crash the decision,
3640
+ # which the hook's guard would turn into a silent pass (plan 139)
3641
+ args = inp.get("args") if isinstance(inp.get("args"), dict) else {}
3642
+ subs = [inp.get("subagent_type"), args.get("subagent_type"), inp.get("agent")]
3589
3643
  subs += [item.get("agent") for item in inp.get("tasks") or ()
3590
3644
  if isinstance(item, dict)]
3591
3645
  # The body a shell call writes, read once: the three write-tool-only rules
@@ -3705,9 +3759,10 @@ def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
3705
3759
  if reason:
3706
3760
  return _deny(session_id, "task", reason, tool, inp, base)
3707
3761
  # Workspace: tezgah state belongs under .tezgah/, never a root plans/,
3708
- # research/ or analysis/ the project does not own (see workspace_reason).
3762
+ # research/ or analysis/ the project does not own (see workspace_reason),
3763
+ # nor a hidden dir under .tezgah/research/ (see research_hidden_reason).
3709
3764
  if not off("workspace-off") and t in WRITE_TOOLS + BASH_TOOLS:
3710
- reason = workspace_reason(inp, cwd, base)
3765
+ reason = workspace_reason(inp, cwd, base) or research_hidden_reason(inp, cwd, base)
3711
3766
  if reason:
3712
3767
  return _deny(session_id, "workspace", reason, tool, inp, base)
3713
3768
  # MCP: a server's effect tool lands text the way a write tool or a `gh`
@@ -3896,7 +3951,8 @@ ASK_NO_DEFAULT = (
3896
3951
  "default (omp `ask`: `recommended`; AskUserQuestion: a \"({RECOMMENDED})\" "
3897
3952
  "option), and the work proceeds on it unless the step waits on the user's "
3898
3953
  "answer by nature (a force push, a delete of what the session did not "
3899
- "make, production, a credential, a spend over the cap). ")
3954
+ "make, production, a credential, a spend over the cap, a recommended "
3955
+ "variant, a way around a tezgah refusal, another session's work). ")
3900
3956
  ASK_GRANTED = (
3901
3957
  "tezgah: the user already granted %s this session; that step is taken, "
3902
3958
  "not asked again.")
@@ -387,13 +387,22 @@ SHAPE_BLOCKS = frozenset(("placating opener", "forbidden closer", "list cap",
387
387
  # agent could decide is no false completion. Counted in `blocked_claims` under
388
388
  # their own class, kept out of `false_completion`.
389
389
  LIST_BLOCKS = frozenset(("residual work", "ready steps", "stopped slice",
390
- "needless ask"))
390
+ "needless ask", "handed step"))
391
+ # The host permission modes in which the session can neither write nor run: a
392
+ # plan-mode turn reads and proposes, so the step it names is the user's to
393
+ # approve, not one it could have taken (`read_only_turn`). Claude, Command
394
+ # Code and Qwen spell it `plan`, Droid `spec`; omp's extension sends `plan`
395
+ # from `sessionModes.getState()`. A host that sends no mode reads as `default`,
396
+ # which is what every rule did before the field.
397
+ READ_ONLY_MODES = frozenset(("plan", "spec"))
391
398
 
392
399
  WRITE_TOOLS = ("edit", "write", "multiedit", "notebookedit", "apply_patch",
393
400
  "str_replace_editor", "create_file", "str_replace", "edit_file",
394
401
  "write_file", "search_replace")
402
+ # `monitor` is Claude's and Qwen's background watch: its `command` is a shell
403
+ # command (review of plan 138), so every shell rule reads it.
395
404
  BASH_TOOLS = ("bash", "shell", "command", "exec_command", "run_command",
396
- "powershell", "pwsh")
405
+ "powershell", "pwsh", "monitor")
397
406
  # The read/search tools: known calls that do no step of work and that no rule
398
407
  # reads a row for, spelled as the hosts send them (Claude/Cursor's capitalized
399
408
  # set, omp's lowercase one, codex's `grep`). They are not `unknown` - the ledger
@@ -517,7 +526,7 @@ LEDGER_FIELDS = frozenset(("id", "exit", "out_bytes", "fail_class", "workspace",
517
526
  "check", "key", "block", "repo", "empty_run", "cause",
518
527
  "host", "switches", "harness", "authorized", "inflight",
519
528
  "reask", "open", "ready", "blocked", "heads", "residual",
520
- "questions", "defaults", "taken", "partial"))
529
+ "questions", "defaults", "taken", "partial", "mode"))
521
530
 
522
531
  # The row contract's own version, stamped by the writer beside `kind` and `ts` so
523
532
  # it is not a caller field. It exists because a row is read back to decide a
@@ -3220,7 +3229,7 @@ def classify(tool, inp, cwd=None):
3220
3229
  # host can put in front of the model; the taint notice that flags a later effect
3221
3230
  # in such a turn is the untrusted module's own, not the gate's.
3222
3231
  WEB_TOOLS = ("web_search", "websearch", "web_fetch", "webfetch", "fetch",
3223
- "browser", "browse")
3232
+ "browser", "browse", "read_web_page")
3224
3233
  MCP_TOOL = re.compile(r"^mcp__", re.I)
3225
3234
  # The verb classes of an MCP tool, read off its name: what the server is asked to
3226
3235
  # do picks which of the gate's existing content rules read the payload
@@ -4058,26 +4067,67 @@ NEGATION_BEFORE = re.compile(r"(?:%s)(?:\s+\S+){0,2}\s+\Z" % alts("negation_befo
4058
4067
  QUESTION_PARTICLE = re.compile(r"\s*(?:%s)\b" % alts("question_particle"), re.I)
4059
4068
  CLAUSE_END = re.compile(r"[.!?\n,;:\u2014\u2013()]|\b(?:%s)\b" % alts("clause_join"),
4060
4069
  re.I)
4070
+ # The first-person claim that a command or a check was run ("test ettim", "i
4071
+ # ran the suite"): the one claim a read-only turn cannot have earned, read by
4072
+ # the `plan claim` class. Narrower than DONE/VERIFIED on purpose - a plan
4073
+ # states its acceptance in those words (plan 142, review round 2).
4074
+ RAN_IT = re.compile(alts("ask", "ran_it"), re.I)
4061
4075
 
4062
4076
 
4063
- def asserted_claims(text):
4064
- """DONE/VERIFIED, with a claim word inside a question or under a negation of its
4065
- own clause not counted. Read by `_stop_block` on the no-work path and by
4066
- `stop_reason` for the claim row; a turn that did work is still judged on its
4067
- rows. ponytail: a window of words, not a parser."""
4077
+ def _asserted(pattern, text):
4078
+ """The first match of `pattern` in `text` that is neither inside a question
4079
+ nor under a negation of its own clause, else None. ponytail: a window of
4080
+ words, not a parser."""
4068
4081
  t = str(text or "")
4082
+ for m in pattern.finditer(t):
4083
+ before = CLAUSE_END.split(t[max(0, m.start() - 80):m.start()])[-1]
4084
+ end = CLAUSE_END.search(t, m.end())
4085
+ asked = (QUESTION_PARTICLE.match(t, m.end())
4086
+ or (end and end.group(0) == "?"))
4087
+ if not (NEGATION_BEFORE.search(before) or asked):
4088
+ return m
4089
+ return None
4069
4090
 
4070
- def said(pattern):
4071
- for m in pattern.finditer(t):
4072
- before = CLAUSE_END.split(t[max(0, m.start() - 80):m.start()])[-1]
4073
- end = CLAUSE_END.search(t, m.end())
4074
- asked = (QUESTION_PARTICLE.match(t, m.end())
4075
- or (end and end.group(0) == "?"))
4076
- if not (NEGATION_BEFORE.search(before) or asked):
4077
- return True
4078
- return False
4079
4091
 
4080
- return said(DONE), said(VERIFIED)
4092
+ def asserted_claims(text):
4093
+ """DONE/VERIFIED, with a claim word inside a question or under a negation of
4094
+ its own clause not counted (`_asserted`). Read by `_stop_block` on the
4095
+ no-work path and by `stop_reason` for the claim row; a turn that did work is
4096
+ still judged on its rows."""
4097
+ return bool(_asserted(DONE, text)), bool(_asserted(VERIFIED, text))
4098
+
4099
+
4100
+ def reply_prose(value):
4101
+ """The reply's prose, whatever the host handed the Stop rule (plan 143).
4102
+
4103
+ omp's `session_stop` carries `last_assistant_message` as the message
4104
+ OBJECT its `getLastAssistantMessage()` returns, not as text, and the bridge
4105
+ passed it through: the rule then judged `str(dict)` - one line, no list
4106
+ items, the prose diluted by the object's own fields. Measured on the
4107
+ owner's ledger, 1643 of 1656 omp `shape` rows of 45 days carried
4108
+ `lines: 1`, against 209 of 231 multi-line on every other host, so every
4109
+ line-shaped class (the list cap, the residual sections, the question the
4110
+ reply ends on, the handed step) read a text that had no lines.
4111
+
4112
+ A string passes through. A message object yields its `text` blocks, joined
4113
+ the way the model wrote them; a thinking or tool block is not the reply.
4114
+ Anything else is no reply at all, which is what the rule already does with
4115
+ a missing one: it never becomes a judgement on a repr.
4116
+ """
4117
+ if isinstance(value, str):
4118
+ return value
4119
+ if isinstance(value, dict):
4120
+ blocks = value.get("content")
4121
+ if isinstance(blocks, str):
4122
+ return blocks
4123
+ if isinstance(blocks, list):
4124
+ return "\n".join(
4125
+ block["text"] for block in blocks
4126
+ if isinstance(block, dict) and block.get("type") == "text"
4127
+ and isinstance(block.get("text"), str)).strip()
4128
+ if isinstance(value.get("text"), str):
4129
+ return value["text"]
4130
+ return ""
4081
4131
 
4082
4132
 
4083
4133
  def passing_check(entry):
@@ -4840,10 +4890,14 @@ def _record_only(refused, turns, after_block, cls):
4840
4890
 
4841
4891
 
4842
4892
  def stop_reason(text, session_id, cwd=None, after_block=0,
4843
- subagent=False, agent=None, parent=None):
4893
+ subagent=False, agent=None, parent=None, mode=None):
4844
4894
  """Why this turn must not end yet, or None. Used by the Stop hooks (Claude,
4845
4895
  Codex, omp and Cursor, which share the payload fields and the block envelope).
4846
4896
  `cwd` is the session's directory, read for the repo's `.no-adhd` mark.
4897
+ `mode` is the host's permission mode for this turn (Claude, Command Code
4898
+ and Qwen send `permission_mode`; omp's extension sends its plan state as
4899
+ one): a read-only one (`read_only_turn`) exempts the approval question the
4900
+ mode asks for and refuses the done/tested claim it cannot have earned.
4847
4901
 
4848
4902
  The verdict is read off the current turn's rows (`turn_rows`) and not the
4849
4903
  whole ledger: the file grows with the session, the handler runs once per
@@ -4889,15 +4943,19 @@ def stop_reason(text, session_id, cwd=None, after_block=0,
4889
4943
  the child keeps a ledger of its own (omp's task sessions); the child's own
4890
4944
  rows still judge it. Both return None. A reply counts as
4891
4945
  claiming only with `asserted_claims`: "Testler geçti mi?" leaves no
4892
- `claim ok` row."""
4946
+ `claim ok` row. `text` is read through `reply_prose`, so a host that hands
4947
+ over a message object rather than its text is judged on the prose (plan
4948
+ 143)."""
4949
+ text = reply_prose(text)
4893
4950
  rows, turns = turn_rows(session_id, turns=True)
4894
4951
  chain, rows = (_turn_refusals(session_id, rows) if turns
4895
4952
  else (list(filter(_blocked, rows)), rows))
4896
4953
  refused = [str(entry.get("detail"))[len("blocked: "):] for entry in chain]
4897
4954
  if after_block and not subagent and not refused:
4898
4955
  return None
4956
+ read_only = read_only_turn(mode)
4899
4957
  cls, reason = _stop_block(text, session_id, rows=rows, cwd=cwd,
4900
- shape=not subagent)
4958
+ shape=not subagent, read_only=read_only)
4901
4959
  record_only = subagent or bool(
4902
4960
  (refused if turns else after_block)
4903
4961
  and (not reason or _record_only(refused, turns, after_block, cls)))
@@ -4905,7 +4963,11 @@ def stop_reason(text, session_id, cwd=None, after_block=0,
4905
4963
  # Keyed like the claim row, so Cursor's re-run of the handler on a follow-up
4906
4964
  # does not count the same reply twice; above the early return, so a reply
4907
4965
  # with no claim vocabulary still leaves its row.
4966
+ # `mode` rides every row of a read-only turn: the verdict depended on it,
4967
+ # and a replay of the ledger reads it back (`tezgah_replay._replay_one`).
4908
4968
  shape = reply_shape(text)
4969
+ if read_only:
4970
+ shape = dict(shape, mode=str(mode).strip().lower())
4909
4971
  if not record_only and not any(entry.get("kind") == "shape"
4910
4972
  and entry.get("id") == key for entry in rows):
4911
4973
  note(session_id, "shape", ",".join(shape_flags(text)) or "ok", id=key,
@@ -5356,7 +5418,8 @@ def _settled(rows):
5356
5418
  return not after or _bookkeeping_turn(after)
5357
5419
 
5358
5420
 
5359
- def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
5421
+ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None,
5422
+ read_only=False):
5360
5423
  """stop_reason's decision as (reason class, block text), without the ledger
5361
5424
  side effect. The class names the branch that refused the turn; the text is
5362
5425
  what the host shows the model.
@@ -5413,11 +5476,18 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
5413
5476
  `fold` is the evidence fold the selector below calls. None, the default,
5414
5477
  is the temporal spec's (`tezgah_stopspec.fold`: the class from its formulas,
5415
5478
  the text from `_evidence_text`), and that outer call fills the text's
5416
- placeholders once (`_said`)."""
5479
+ placeholders once (`_said`).
5480
+
5481
+ `read_only` is the host's word that this turn ran in a plan mode
5482
+ (`read_only_turn`), where no command and no write was possible. Two classes
5483
+ read it: `plan claim` below, which refuses a done/tested claim such a turn
5484
+ cannot have behind it, and the `needless ask` exemption in
5485
+ `_residual_block`, since the approval question is what the mode asks for."""
5417
5486
  if fold is None:
5418
5487
  # imported here: tezgah_stopspec imports this module at its top
5419
5488
  import tezgah_stopspec
5420
- verdict = _stop_block(text, session_id, rows, cwd, shape, tezgah_stopspec.fold)
5489
+ verdict = _stop_block(text, session_id, rows, cwd, shape,
5490
+ tezgah_stopspec.fold, read_only)
5421
5491
  return (verdict[0], verdict[1] and _said(verdict[1]))
5422
5492
  t = str(text or "")
5423
5493
  shaped = _shape_block(t, cwd) if shape else (None, None)
@@ -5431,6 +5501,19 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
5431
5501
  return ("evidence tampered", TAMPERED)
5432
5502
  if (done or verified) and any(r.get(ORPHAN) for r in rows):
5433
5503
  return ("evidence tampered", ORPHANED)
5504
+ # A read-only turn (the host's plan mode) ran no check, so a reply that says
5505
+ # it ran one says what cannot have happened - unless a check of its own
5506
+ # passed, which is evidence like any other. The trigger is the first-person
5507
+ # run claim (RAN_IT: "test ettim", "i ran the suite"), never the done or
5508
+ # verified vocabulary: a plan states its acceptance in those words ("Done
5509
+ # when all tests pass", "her adım `pytest -k apps` ile doğrulanır") and
5510
+ # review round 2 of plan 142 caught both refused. Above `_admitted`:
5511
+ # "doğrulanmadı" repairs an unverified claim, and here nothing was checked.
5512
+ if read_only and not any(passing_check(r) for r in rows):
5513
+ ran = _asserted(RAN_IT, t)
5514
+ if ran:
5515
+ return ("plan claim",
5516
+ PLAN_CLAIM % cut(" ".join(ran.group(0).split()), 64))
5434
5517
  fold = _admitted(fold, t)
5435
5518
  # The trigger is the turn's own evidence, not its words. The claim vocabulary
5436
5519
  # below catches a claim-shaped reply; it missed the same unfounded state
@@ -5487,11 +5570,12 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
5487
5570
  # shape that used to leave such a claim unjudged.
5488
5571
  external = _external_claim(t)
5489
5572
  if (not worked or read) and not (done or verified) and not external:
5490
- return _residual_block(t, rows, shape, session_id=session_id, cwd=cwd)
5573
+ return _residual_block(t, rows, shape, session_id=session_id, cwd=cwd,
5574
+ read_only=read_only)
5491
5575
  if lost:
5492
5576
  return _residual_block(t, rows, shape,
5493
5577
  fold(rows, worked | {BEGAN_KIND}, external),
5494
- session_id, cwd)
5578
+ session_id, cwd, read_only)
5495
5579
  # A check that answered with an outcome nobody saw (exit 0 and nothing
5496
5580
  # returned, a status a pipe owns: not `passing_check`) is no pass, and as a
5497
5581
  # check it is no work row either. A done or tested claim resting on it owes
@@ -5524,7 +5608,7 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
5524
5608
  if not external and not unseen and _settled(srows):
5525
5609
  return _residual_block(t, rows, shape, fold(
5526
5610
  _before_turn(srows), True, None, "this session"),
5527
- session_id, cwd)
5611
+ session_id, cwd, read_only)
5528
5612
  if (not worked or quiet) and (done or verified):
5529
5613
  # A claim in a turn that did nothing: "Tamamlandı, tüm testler
5530
5614
  # geçti" after a turn that edited and ended "doğrulanmadı" was
@@ -5545,7 +5629,7 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
5545
5629
  if unseen:
5546
5630
  worked = worked | {"verify_ok"}
5547
5631
  return _residual_block(t, rows, shape, fold(judged, worked, external),
5548
- session_id, cwd)
5632
+ session_id, cwd, read_only)
5549
5633
 
5550
5634
 
5551
5635
  def _before_turn(rows):
@@ -6061,6 +6145,27 @@ OPEN_TODO = (
6061
6145
  "{ACCESS}, {IRREVERSIBLE} or {ENV}, or marked blocked in the todo list "
6062
6146
  "with its reason.")
6063
6147
 
6148
+ # The `handed step` class (plan 142): the reply tells the reader to run a
6149
+ # command this session ran commands beside (`handed_steps`). Filled with up to
6150
+ # three of those lines.
6151
+ HANDED_STEP = (
6152
+ "{HANDED_LEAD}The reply hands the user %d step(s) this session can run "
6153
+ "itself: %s. Run each one now and report what it printed. A step stays the "
6154
+ "user's only when it is outward with no grant from this session, when it "
6155
+ "cannot be undone, or when it needs {ACCESS} or {ENV} - and then its own "
6156
+ "line says which, with the command that failed. In a host plan mode, where "
6157
+ "nothing can run, the plan is the deliverable and the step runs in the turn "
6158
+ "after the approval.")
6159
+
6160
+ # The `plan claim` class (plan 142): a done/tested claim made in a turn the
6161
+ # host ran in a read-only mode (`read_only_turn`), where no command and no
6162
+ # write was possible, so nothing can be behind it.
6163
+ PLAN_CLAIM = (
6164
+ "{PLAN_LEAD}This turn ran in the host's read-only plan mode: no command "
6165
+ "and no write was possible, so \"%s\" has nothing behind it. Say what the "
6166
+ "plan is and which command will prove it, and claim the result in the turn "
6167
+ "that runs it, after the plan is approved.")
6168
+
6064
6169
 
6065
6170
  def _tr_lower(text):
6066
6171
  """`text` lowercased the Turkish way (I -> ı, İ -> i): `str.lower` reads
@@ -6378,6 +6483,10 @@ NEVER_DEFAULT = re.compile(alts("ask", "never_default"), re.I)
6378
6483
  # A reply that names a plan it wrote, or asks to build one: building a plan the
6379
6484
  # turn wrote is class F (plan 105), so asking it is the policy, not a needless ask
6380
6485
  PLAN_BUILD = re.compile(alts("ask", "plan_build"), re.I)
6486
+ # A last line asking the owner to pick a variant or option a research, analysis
6487
+ # or review recommends, to take a way around a tezgah refusal, or to decide
6488
+ # about another session's work (its line, plan or branch): class F (plan 136)
6489
+ OWNER_CHOICE = re.compile(alts("ask", "owner_choice"), re.I)
6381
6490
  SPEND = re.compile(alts("ask", "spend"), re.I)
6382
6491
  AMOUNT = re.compile(r"\$\s?(\d+(?:\.\d+)?)|(\d+(?:\.\d+)?)\s?(?:\$|usd\b|dolar)", re.I)
6383
6492
  DEFAULT_MARK = re.compile(alts("ask", "default_mark"), re.I)
@@ -6398,10 +6507,12 @@ NEEDLESS_ASK = (
6398
6507
  "merge, a run parameter within the standing spend cap - are taken and "
6399
6508
  "stated in one line, and an outward step the user granted this session is "
6400
6509
  "taken, not asked again. An outward step the user has not granted waits "
6401
- "for the answer and never proceeds on a default, and so does building a "
6402
- "plan this turn wrote. Do it now, or, when only "
6403
- "the user can decide it, put it under a \"{APPROVAL_HEADING}\" heading with "
6404
- "a \"{RECOMMENDED}:\" default and proceed on that default.")
6510
+ "for the answer and never proceeds on a default, and so do building a "
6511
+ "plan this turn wrote, choosing a variant or option a research, analysis "
6512
+ "or review recommends, a way around a tezgah refusal and deciding about "
6513
+ "another session's work. Do it now, or, when it is a taste or intent fork "
6514
+ "only the user can decide, put it under a \"{APPROVAL_HEADING}\" heading "
6515
+ "with a \"{RECOMMENDED}:\" default and proceed on that default.")
6405
6516
 
6406
6517
 
6407
6518
  def outward_classes(text):
@@ -6449,7 +6560,9 @@ def needless_ask(text, granted=frozenset):
6449
6560
  """The question the reply ends on when the agent can decide it, else None.
6450
6561
 
6451
6562
  The last line asks (QUESTION_END), its paragraph carries no recommended
6452
- default (DEFAULT_MARK) and it names no class-F step (`never_default`); it
6563
+ default (DEFAULT_MARK) and it names no class-F step (`never_default`, or
6564
+ OWNER_CHOICE: a variant or option to pick, a way around a refusal, another
6565
+ session's work); it
6453
6566
  is then needless when it names an outward step (OUTWARD) the user granted
6454
6567
  this session, every one it names (`granted()`, called only then, so a reply
6455
6568
  that ends on no question reads no ledger), or, naming none, when it asks a
@@ -6460,7 +6573,8 @@ def needless_ask(text, granted=frozenset):
6460
6573
  if not lines or not QUESTION_END.search(lines[-1]):
6461
6574
  return None
6462
6575
  last = lines[-1]
6463
- if (never_default(last) or PLAN_BUILD.search(_tr_lower(t))
6576
+ if (never_default(last) or OWNER_CHOICE.search(_tr_lower(last))
6577
+ or PLAN_BUILD.search(_tr_lower(t))
6464
6578
  or DEFAULT_MARK.search(_tr_lower(re.split(r"\n\s*\n", t)[-1]))):
6465
6579
  return None
6466
6580
  low = _tr_lower(last)
@@ -6472,6 +6586,56 @@ def needless_ask(text, granted=frozenset):
6472
6586
  return None
6473
6587
 
6474
6588
 
6589
+ # The "you run it" shape: a line that tells the reader to run something
6590
+ # (`ask.hand_over`), beside a command in backticks (CODE_SPAN, the module's
6591
+ # own: a fence line carries no backtick span and never qualifies).
6592
+ HAND_OVER = re.compile(alts("ask", "hand_over"), re.I)
6593
+ # The steps the owner runs by design, whatever the session can do: tezgah's own
6594
+ # consent command, its installation and a machine-level `brew` step (the
6595
+ # session-scope rule). An outward or class-F step is read off the line itself
6596
+ # (`outward_classes`, `never_default`).
6597
+ OWNER_STEP = re.compile(r"(?i)\btezgah-(?:consent|setup|update)\b|\bconsent\b|\bbrew\b")
6598
+
6599
+
6600
+ def read_only_turn(mode):
6601
+ """True when the host says this turn ran in a mode that can neither write
6602
+ nor run (READ_ONLY_MODES). A host that sends nothing reads as False."""
6603
+ return str(mode or "").strip().lower().replace("_", "-") in READ_ONLY_MODES
6604
+
6605
+
6606
+ def handed_steps(text, rows, read_only=False):
6607
+ """The lines that hand the reader a step this session could have taken.
6608
+
6609
+ A line qualifies when it names a command in backticks and tells the reader
6610
+ to run it (HAND_OVER: the reader addressed as the one who runs it, never
6611
+ the "you can run X" of an answer about how something is done). A table row
6612
+ (TABLE_ROW) and a quoted line are not
6613
+ the reply speaking: a refusal or a log the reply quotes carries the same
6614
+ words. It is kept - the ask policy's, not the agent's - when it names an
6615
+ outward step (`outward_classes`), a class-F step
6616
+ (`never_default`), the owner's consent or tezgah's own install
6617
+ (OWNER_STEP), or when the line types its blocker. The turn must have run a
6618
+ command of its own (COMMAND_KINDS): a turn that ran none could not have run
6619
+ this one either, which keeps the class inert on a host that sends no mode.
6620
+ `read_only` closes it outright: in a plan mode every step waits for the
6621
+ approval, which is what the contract tells the turn to do."""
6622
+ if read_only or not any(str(r.get("kind")) in COMMAND_KINDS for r in rows):
6623
+ return []
6624
+ out = []
6625
+ for line in str(text or "").splitlines():
6626
+ said = line.strip()
6627
+ if said.startswith(("|", ">")) or TABLE_ROW.match(line):
6628
+ continue
6629
+ if not (CODE_SPAN.search(said) and HAND_OVER.search(said)):
6630
+ continue
6631
+ prose = _prose(said)
6632
+ if (outward_classes(said) or never_default(said) or OWNER_STEP.search(said)
6633
+ or BLOCKER_SAID.search(prose) or BLOCKER_TRIED.search(prose)):
6634
+ continue
6635
+ out.append(said)
6636
+ return out
6637
+
6638
+
6475
6639
  def undefaulted(inp):
6476
6640
  """(questions, with a default) of an ask-tool payload: omp's `ask` marks
6477
6641
  the default with a question's `recommended` index, Claude's
@@ -6527,14 +6691,18 @@ def unblocked_items(text, rows):
6527
6691
 
6528
6692
 
6529
6693
  def _residual_block(text, rows, shape, verdict=(None, None), session_id=None,
6530
- cwd=None):
6694
+ cwd=None, read_only=False):
6531
6695
  """`verdict` when it refuses; else, in a turn that did work (a STEP_KINDS
6532
6696
  row), the `residual work` class when `unblocked_items` names one, or when a
6533
6697
  subagent ended in the turn handing up items no type accounts for (the
6534
6698
  `residual` count on its `subagent_end` row) and this reply types none; else
6535
6699
  the `needless ask` class when `needless_ask` names the question the reply
6536
6700
  ends on (any turn, worked or not: the plan proposal that ends "Başlayayım
6537
- mı?" is its measured case); else the `ready steps` class when `ready_left`
6701
+ mı?" is its measured case) - except in a read-only turn, where the host's
6702
+ plan mode asks for that approval and the agent cannot take the step it
6703
+ would be asked about; else the `handed step` class when `handed_steps`
6704
+ names a step the reply left to the reader; else the `ready steps` class
6705
+ when `ready_left`
6538
6706
  names one; else the `open todo` class when `open_left` names one; else the
6539
6707
  `stopped slice` class when `_stopped_slice` finds one; else `verdict`. Not
6540
6708
  asked for a subagent's end (`shape` false) or a nested session: both
@@ -6566,9 +6734,15 @@ def _residual_block(text, rows, shape, verdict=(None, None), session_id=None,
6566
6734
  move = next(filter(None, (_limit_move(item, failed, spawn_failed)
6567
6735
  for _label, item in left)), None)
6568
6736
  return ("residual work", RESIDUAL_WORK % named + (LIMIT_MOVE % move if move else ""))
6569
- asked = needless_ask(text, lambda: grants_in_force(session_id))
6737
+ asked = None if read_only else needless_ask(
6738
+ text, lambda: grants_in_force(session_id))
6570
6739
  if asked:
6571
6740
  return ("needless ask", NEEDLESS_ASK % cut(asked, 80))
6741
+ passed = handed_steps(text, rows, read_only)
6742
+ if passed:
6743
+ return ("handed step", HANDED_STEP % (
6744
+ len(passed), "; ".join(cut(" ".join(line.split()), 64)
6745
+ for line in passed[:3])))
6572
6746
  todo = _turn_todo(rows, session_id)
6573
6747
  ready = _ready_in(text, todo, session_id, cwd)
6574
6748
  if ready: