@r1z4x/tezgah 1.0.0 → 1.1.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 (56) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/MANIFEST +8 -0
  3. package/bin/tezgah-agents +10 -1
  4. package/bin/tezgah-docs +1 -1
  5. package/bin/tezgah-doctor +11 -0
  6. package/bin/tezgah-research +31 -14
  7. package/bin/tezgah-setup +154 -29
  8. package/bin/tezgah-skill +59 -0
  9. package/bin/tezgah-skill.py +59 -0
  10. package/bin/tezgah-status +62 -1
  11. package/bin/tezgah-triage +5 -3
  12. package/docs/README.md +1 -0
  13. package/docs/architecture.md +5 -2
  14. package/docs/citations-baseline.json +1 -1
  15. package/docs/clarity-baseline.json +9 -9
  16. package/docs/contract.md +68 -26
  17. package/docs/environment.md +6 -3
  18. package/docs/evidence.md +7 -0
  19. package/docs/gate.md +13 -7
  20. package/docs/glossary.md +1 -1
  21. package/docs/hosts.md +48 -1
  22. package/docs/index.json +29 -1
  23. package/docs/judge.md +61 -55
  24. package/docs/models.md +14 -8
  25. package/docs/operations.md +10 -5
  26. package/docs/orca.md +126 -0
  27. package/docs/research.md +38 -3
  28. package/docs/skills.md +74 -0
  29. package/docs/status-line.md +3 -1
  30. package/docs/testing.md +33 -21
  31. package/docs/vision.md +12 -8
  32. package/hooks/projects-pretooluse.py +17 -5
  33. package/hooks/tezgah_agents.py +135 -13
  34. package/hooks/tezgah_components.py +3 -0
  35. package/hooks/tezgah_context.py +65 -22
  36. package/hooks/tezgah_gate.py +133 -17
  37. package/hooks/tezgah_integrity.py +60 -13
  38. package/hooks/tezgah_judge.py +239 -56
  39. package/hooks/tezgah_models.py +41 -5
  40. package/hooks/tezgah_orca.py +144 -0
  41. package/hooks/tezgah_paths.py +40 -6
  42. package/hooks/tezgah_policy.py +109 -35
  43. package/hooks/tezgah_research.py +104 -8
  44. package/hooks/tezgah_skill_pick.py +459 -8
  45. package/hooks/tezgah_stack.py +382 -0
  46. package/hooks/tezgah_stopspec.py +2 -2
  47. package/hosts/codex/hook.py +7 -1
  48. package/hosts/cursor/hook.py +7 -4
  49. package/hosts/omp/hook.py +12 -6
  50. package/hosts/omp/tezgah-hook.ts.in +20 -7
  51. package/hosts/opencode/plugins/tezgah.js +18 -9
  52. package/output-styles/tezgah.md +34 -19
  53. package/package.json +1 -1
  54. package/skills/i-have-adhd/SKILL.md +2 -2
  55. package/skills/research/SKILL.md +60 -17
  56. package/skills/tezgah-contract/SKILL.md +55 -13
@@ -15,6 +15,7 @@ import sys
15
15
  import time
16
16
 
17
17
  import tezgah_embed
18
+ import tezgah_orca
18
19
  import tezgah_research
19
20
  from tezgah_guard import import_crash_mark
20
21
  from tezgah_integrity import (STEP_KINDS, _heredocs, _path as _ledger_path,
@@ -333,6 +334,15 @@ PROMPT_HINTS = (
333
334
  r"nerede (kullanıl|çağrıl)\w*|tanım\w* nerede|kullanım yerleri|"
334
335
  r"çağrı (zinciri|grafiği)|bağımlılık (grafiği|ağacı)|"
335
336
  r"yeniden adlandır\w*)\b"),
337
+ # Two or more independent items: a numbered or bulleted list of at least
338
+ # two entries on their own lines, or the explicit delegation ask. The owner
339
+ # had to say "do everything with subagents and in parallel" on every task
340
+ # because nothing armed the fan-out rule from the shape of the request.
341
+ # `parallelize` alone is code work, so only the delegation phrasings count.
342
+ ("fanout", r"(?ms:^[ \t]*\d+[.)][ \t]+\S.*?^[ \t]*\d+[.)][ \t]+\S)|"
343
+ r"(?ms:^[ \t]*[-*•][ \t]+\S.*?^[ \t]*[-*•][ \t]+\S)|"
344
+ r"\b(?:in parallel|paralel\w*|sub-?agents?|alt ?ajan\w*|"
345
+ r"worktrees?|fan[- ]?out|orchestrat\w*|orkestra\w*)\b"),
336
346
  )
337
347
 
338
348
  # the detached auto-index worker (lock-guarded, retrying); same dir as this file
@@ -363,6 +373,7 @@ CORE_RULES = (
363
373
  ("attribution", "**No AI attribution, ever, on any host.**"),
364
374
  ("lang", "**Identifiers and messages stay English.**"),
365
375
  ("workspace", "**Workspace: `.tezgah/` only.**"),
376
+ ("fanout", "**Parallel by default: fan out independent items.**"),
366
377
  )
367
378
 
368
379
  # The per-turn reminder's clause for each switchable rule, matched against
@@ -1226,6 +1237,7 @@ def switches(cwd):
1226
1237
  drop.add("product")
1227
1238
  disabled.append("research-off")
1228
1239
  if off("orchestrate-off"):
1240
+ drop.add("fanout")
1229
1241
  disabled.append("orchestrate-off")
1230
1242
  if off("lang-off"):
1231
1243
  drop.add("lang")
@@ -1368,12 +1380,17 @@ def session_of(payload):
1368
1380
  # 4000 B this rule's own 282 B dropped `consult`, held at 4400 B a 111 B core
1369
1381
  # growth dropped it again, and at 5000 B the brief's operative clauses (the
1370
1382
  # inter-agent language and the lessons instruction, plan 056) would have - bloat
1371
- # paid for with a rule, which is the failure this bound exists to prevent. A
1383
+ # paid for with a rule, which is the failure this bound exists to prevent. The
1384
+ # same reasoning moved the two session budgets on 2026-10-07: the ponytail and
1385
+ # ADHD rules inline from the first turn (+1042 B), the fan-out pointer (+112 B)
1386
+ # and the `tezgah-skill` line (+113 B) took the healthy fixture's session_start
1387
+ # from 10579 B to 12059 B, so at 12000 B a healthy repo gave up its lessons
1388
+ # and sibling checkouts to pay for core rules; 13000 B restores the headroom. A
1372
1389
  # budget in bytes, not tokens: this file has no tokenizer and a wrong estimate
1373
1390
  # would be worse than a bound.
1374
- CONTEXT_BUDGET = {"session_start": 12000, "post_compact": 12000,
1391
+ CONTEXT_BUDGET = {"session_start": 13000, "post_compact": 13000,
1375
1392
  "subagent_start": 5500, "user_prompt": 6000}
1376
- DEFAULT_BUDGET = 12000
1393
+ DEFAULT_BUDGET = 13000
1377
1394
  # The blocks in the order they are given up when the budget is exceeded, lowest
1378
1395
  # value first: text another surface already carries (the plan table lives in the
1379
1396
  # plan-status skill, the sibling checkouts in `tezgah-research --all`, the
@@ -1395,7 +1412,7 @@ DEFAULT_BUDGET = 12000
1395
1412
  # spend them would turn bloat into rule loss - which is the failure the budget
1396
1413
  # exists to prevent, not one it may cause.
1397
1414
  DROP_ORDER = ("knowledge", "worktrees", "lessons", "skill", "lessons_turn",
1398
- "plans", "subagents", "steer", "consult", "research",
1415
+ "plans", "subagents", "steer", "orca", "consult", "research",
1399
1416
  "research_broken", "graph", "offnote", "orchestrate", "index",
1400
1417
  "resume", "scratch", "task", "delta", "pointer")
1401
1418
 
@@ -1987,9 +2004,14 @@ def context_for(event, cwd, payload=None, with_core=True):
1987
2004
  # model as truncated one-liners, so which entry to look at first is the one
1988
2005
  # thing this turn cannot work out for itself. One call, one line, cached per
1989
2006
  # prompt - and nothing is appended when the judge is gone, the answer is
1990
- # `none`, or the arming file is absent (hooks/tezgah_skill_pick).
2007
+ # `none`, or the arming file is absent (hooks/tezgah_skill_pick). When
2008
+ # it gives no line, the model-free section search may: default on, one
2009
+ # line at most, each section once per session (`section_hint`).
1991
2010
  if prompt and tezgah_skill_pick:
1992
2011
  hint = tezgah_skill_pick.suggest(prompt, session_id)
2012
+ if not hint:
2013
+ host = (payload or {}).get("host") if isinstance(payload, dict) else None
2014
+ hint = tezgah_skill_pick.section_hint(prompt, session_id, host=host)
1993
2015
  if hint:
1994
2016
  parts.append(("skill", hint))
1995
2017
  # C1 and C3 ride this turn because it is the only channel a live state
@@ -2074,8 +2096,18 @@ def context_for(event, cwd, payload=None, with_core=True):
2074
2096
  # indexers on the same repo, so only the parent session triggers one.
2075
2097
  status = (autoindex(root) if event != "subagent_start"
2076
2098
  else "index handled by the parent session")
2077
- parts.append(("graph", "Graph index: %s (project %s)."
2078
- % (status, slug(root))))
2099
+ # The tool by the name this host calls it: a session whose prompt arms
2100
+ # no graph rule otherwise never learns which tool the graph is, and
2101
+ # omp's own prompt sends code discovery to `find`. A subagent gets the
2102
+ # CLI (omp's MCP device refuses concurrent writes, see tezgah_agents).
2103
+ from tezgah_gate import graph_tool # lazy: keep hook import cost minimal
2104
+ how = ("`codegraph explore|callers|impact <symbol>` in a shell"
2105
+ if event == "subagent_start"
2106
+ else graph_tool((payload or {}).get("host")))
2107
+ parts.append(("graph", "Graph index: %s (project %s). Definitions, "
2108
+ "callers and blast radius: %s first; grep only for "
2109
+ "literal text, configs and docs."
2110
+ % (status, slug(root), how)))
2079
2111
  else:
2080
2112
  parts.append(("graph", "Graph: codegraph is not installed, so use "
2081
2113
  "grep/find and say the answer came from text "
@@ -2131,6 +2163,11 @@ def context_for(event, cwd, payload=None, with_core=True):
2131
2163
  siblings = sibling_line(root) if root not in roots() else ""
2132
2164
  if siblings:
2133
2165
  parts.append(("worktrees", siblings))
2166
+ # Inside Orca a checkout or a long job belongs to Orca, or its sidebar
2167
+ # never shows it; env only, so this forks nothing.
2168
+ orca = tezgah_orca.context_line()
2169
+ if orca:
2170
+ parts.append(("orca", orca))
2134
2171
  # The project's own rule files, agents and skills (tezgah-migrate's
2135
2172
  # index): they stay where the project keeps them, and one line makes a
2136
2173
  # session read the rows its task touches instead of never seeing them.
@@ -2538,30 +2575,36 @@ def index_notice(cwd):
2538
2575
  says less than "↻" but still says something the turn needs: no stamp, or no
2539
2576
  readable HEAD, means the graph's age is unknown rather than current, and
2540
2577
  staying silent there is what let an unverifiable index answer with the
2541
- index's authority."""
2578
+ index's authority.
2579
+
2580
+ The line used to stop there ("re-index before trusting one, or say the
2581
+ answer came from text search") and start nothing: the index is stamped only
2582
+ by the session-start worker, so every turn after a commit told the model to
2583
+ leave the graph for grep. A stale or unknown stamp now starts the same
2584
+ lock-guarded incremental worker the session start runs (`autoindex`), and
2585
+ the line says so."""
2542
2586
  base, _marks = repo_marks(cwd)
2543
2587
  mark = index_mark(cwd, base) if base else ""
2544
- if mark == "?":
2545
- return ("Graph index: this session cannot compare the graph to HEAD (no "
2546
- "readable index stamp), so the graph's age is unknown - it may "
2547
- "describe code that has moved since. Re-index before trusting a "
2548
- "graph answer, or say the answer came from text search.")
2549
- if mark != "\u21bb":
2588
+ if mark not in ("?", "\u21bb"):
2550
2589
  return ""
2590
+ status = autoindex(repo_root(cwd))
2591
+ keep = ("Keep using the graph for code discovery; a file changed since the "
2592
+ "last index may still show its older source there, so read that "
2593
+ "file itself before editing it.")
2594
+ if mark == "?":
2595
+ return ("Graph index: the graph's age is unknown (no readable index "
2596
+ "stamp); %s. %s" % (status, keep))
2551
2597
  from tezgah_gate import index_slug # lazy: keep hook import cost minimal
2552
2598
  slug = index_slug(cwd, base)
2553
2599
  try:
2554
2600
  with open(os.path.join(cache_dir(), slug), encoding="utf-8") as fh:
2555
2601
  stamped = fh.read().strip()
2556
2602
  except OSError:
2557
- return ""
2558
- head = git(repo_root(cwd), "rev-parse", "HEAD")
2559
- if not head or not stamped:
2560
- return ""
2561
- return ("Graph index: the graph is indexed at %s, HEAD is %s - the index is "
2562
- "behind, so a graph answer may describe code that has moved since. "
2563
- "Re-index before trusting one, or say the answer came from text "
2564
- "search." % (stamped[:7], head[:7]))
2603
+ stamped = ""
2604
+ head = git(repo_root(cwd), "rev-parse", "HEAD") or ""
2605
+ return ("Graph index: the graph is indexed at %s, HEAD is %s; %s "
2606
+ "(`codegraph sync`, incremental). %s"
2607
+ % (stamped[:7] or "?", head[:7] or "?", status, keep))
2565
2608
 
2566
2609
 
2567
2610
  def plan_mark(cwd, base):
@@ -4,9 +4,11 @@
4
4
  Rules, all only inside a tezgah root:
5
5
  1. a grep-only explorer subagent is refused, with the graph tools named as the
6
6
  replacement;
7
- 2. the FIRST identifier-shaped search of a session (Grep tool, or grep/rg run
8
- through a shell) in a repo whose own codegraph index exists is nudged
9
- toward the graph once, then every later search passes.
7
+ 2. the FIRST code-symbol search of a session (grep/search/find tool, or
8
+ grep/rg run through a shell; a plain word is literal text and passes) in a
9
+ repo whose own codegraph index exists is nudged toward the graph once;
10
+ every later one passes, with graph_advice's line beside it while the index
11
+ is current.
10
12
  3. a git/gh command that writes an artifact carrying an AI/model credit
11
13
  (Co-Authored-By, "Generated with", a robot emoji, ...) is refused.
12
14
  4. an identical call that already failed LOOP_ATTEMPTS times in the current
@@ -170,7 +172,16 @@ try: # The language rule's detector: the letter set and the word list, in their
170
172
  except ImportError: # pragma: no cover - only where the module has not landed
171
173
  tezgah_lang = None
172
174
 
173
- IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{2,}$")
175
+ # A search for a code symbol, the shape the graph answers: an identifier with an
176
+ # inner underscore or a lower-to-upper step (snake_case, camelCase), or any
177
+ # identifier inside a definition or call pattern (`def run`, `run\(`). A plain
178
+ # word (`TODO`, `error`) is literal text, so it is never nudged or advised.
179
+ SEARCH_SYMBOL = re.compile(
180
+ r"(?:\\b)?(?:(def|class|function|func|fn|interface|struct|type)\s+)?"
181
+ r"([A-Za-z_][A-Za-z0-9_]{2,})(\\?\()?(?:\\b)?")
182
+ # omp's discovery tools: `find` takes a described behavior plus likely terms,
183
+ # `search` and `ast_grep` a pattern; `grep` is every host's regex tool.
184
+ PATTERN_SEARCH_TOOLS = ("grep", "search", "ast_grep")
174
185
  # ponytail: flags with a separate value (grep -A 3 foo) shift the token and the
175
186
  # search passes unnudged; not worth a real argv parser for a once-a-session hint.
176
187
  BASH_SEARCH = re.compile(r"(?:^|[|;&(]\s*|\s)(?:grep|rg)\s+((?:-\S+\s+)*)(\S+)")
@@ -481,18 +492,35 @@ def explored(subagent_type):
481
492
  return str(subagent_type or "").lower() in ("explore", "explorer")
482
493
 
483
494
 
484
- def searched_identifier(tool, inp):
485
- """The bare identifier this call searches for, or None. Host tool names
486
- differ in case (Claude `Grep`/`Bash`, Codex/dsh `bash`), so match lowercased."""
495
+ def symbol_in(text):
496
+ """The code symbol a search string names, or None for literal text."""
497
+ m = SEARCH_SYMBOL.fullmatch(str(text or "").strip().strip("'\""))
498
+ if not m:
499
+ return None
500
+ tok = m.group(2)
501
+ if (m.group(1) or m.group(3) or "_" in tok.strip("_")
502
+ or re.search(r"[a-z][A-Z]", tok)):
503
+ return tok
504
+ return None
505
+
506
+
507
+ def searched_symbol(tool, inp):
508
+ """The code symbol this call searches for, or None. Host tool names differ
509
+ in case (Claude `Grep`/`Bash`, Codex/dsh/omp `bash`), so match lowercased.
510
+ omp's `find` is read too: its own prompt sends code discovery there first,
511
+ so a rule that read only grep never met an omp session."""
487
512
  t = str(tool or "").lower()
488
- if t == "grep":
489
- pat = str(inp.get("pattern", ""))
490
- return pat if IDENT.match(pat) else None
513
+ if t in PATTERN_SEARCH_TOOLS:
514
+ return symbol_in(inp.get("pattern", ""))
515
+ if t == "find" and "query" in inp:
516
+ words = list(inp.get("grep_keywords") or [])
517
+ words += re.findall(r"[A-Za-z_][A-Za-z0-9_]{2,}\(?", str(inp["query"]))
518
+ return next(filter(None, map(symbol_in, words)), None)
491
519
  if t in ("bash", "shell"):
492
520
  for m in BASH_SEARCH.finditer(str(inp.get("command", ""))):
493
- tok = m.group(2).strip("'\"")
494
- if IDENT.match(tok):
495
- return tok
521
+ symbol = symbol_in(m.group(2))
522
+ if symbol:
523
+ return symbol
496
524
  return None
497
525
 
498
526
 
@@ -509,15 +537,56 @@ def index_slug(cwd, base):
509
537
  one, so its own `.codegraph` is read) - and the name returned is still the
510
538
  key the index worker stamps HEAD under, so index_mark and index_notice keep
511
539
  reading the same file."""
540
+ d = index_dir(cwd, base)
541
+ return re.sub(r"[^A-Za-z0-9]+", "-", d).strip("-") if d else None
542
+
543
+
544
+ def index_dir(cwd, base):
545
+ """The directory index_slug names: the closest repo up to `base` that holds
546
+ its own codegraph index, or None (none, or a `.no-graph` met first)."""
512
547
  d = os.path.realpath(cwd)
513
548
  base, rs = os.path.realpath(base), roots()
514
549
  while d.startswith(base) and (d != base or base not in rs):
550
+ if os.path.exists(os.path.join(d, ".no-graph")):
551
+ return None # the repo's own switch: no graph rule here at all
515
552
  if os.path.isfile(os.path.join(d, ".codegraph", "codegraph.db")):
516
- return re.sub(r"[^A-Za-z0-9]+", "-", d).strip("-")
553
+ return d
517
554
  d = os.path.dirname(d)
518
555
  return None
519
556
 
520
557
 
558
+ def _read(path):
559
+ with open(path, encoding="utf-8") as fh:
560
+ return fh.read().strip()
561
+
562
+
563
+ def git_head(repo):
564
+ """HEAD's commit read from `repo`'s git files, or None - no `git` fork,
565
+ because the gate pays it on a tool call. Covers a plain checkout, a linked
566
+ worktree (`.git` file plus `commondir`) and a packed ref."""
567
+ try:
568
+ gd = os.path.join(repo, ".git")
569
+ if os.path.isfile(gd):
570
+ gd = os.path.join(repo, _read(gd).split("gitdir:", 1)[1].strip())
571
+ head = _read(os.path.join(gd, "HEAD"))
572
+ if not head.startswith("ref: "):
573
+ return head or None
574
+ ref = head[5:]
575
+ common = gd
576
+ if os.path.isfile(os.path.join(gd, "commondir")):
577
+ common = os.path.join(gd, _read(os.path.join(gd, "commondir")))
578
+ for top in (gd, common):
579
+ if os.path.isfile(os.path.join(top, ref)):
580
+ return _read(os.path.join(top, ref))
581
+ with open(os.path.join(common, "packed-refs"), encoding="utf-8") as fh:
582
+ for line in fh:
583
+ if line.rstrip("\n").endswith(" " + ref):
584
+ return line.split()[0]
585
+ except (OSError, IndexError):
586
+ pass
587
+ return None
588
+
589
+
521
590
  def nudge_mark(session_id):
522
591
  """The once-per-session mark: sha1(id)[:16], the key opencode's oncePerSession writes."""
523
592
  return os.path.join(cache_dir(), "nudged", hashlib.sha1(
@@ -540,12 +609,59 @@ def first_nudge(session_id):
540
609
  return False
541
610
 
542
611
 
612
+ def graph_tool(host=None):
613
+ """The code-graph tool by the name this host's model calls it: omp mounts an
614
+ MCP server's tools as `xd://` devices, Claude and Codex as `mcp__<server>__`
615
+ (`mcp__plugin_tezgah_codegraph__` on a Claude plugin install), Cursor by
616
+ the bare tool name. Unknown host: the two prefixed spellings, so the line is
617
+ right wherever it lands."""
618
+ if host == "omp":
619
+ return "`xd://mcp__codegraph_explore`"
620
+ if host == "cursor":
621
+ return "the codegraph MCP server's `codegraph_explore`"
622
+ claude = ("`mcp__codegraph__codegraph_explore` (plugin install: "
623
+ "`mcp__plugin_tezgah_codegraph__codegraph_explore`)")
624
+ return claude if host else "%s, on omp `xd://mcp__codegraph_explore`" % claude
625
+
626
+
543
627
  def nudge_reason(symbol):
544
- return ("Code graph index is ready for this repo. Run `codegraph explore %s` (source, callers, "
628
+ return ("Code graph index is ready for this repo. Call the MCP tool %s with "
629
+ "the query `%s`, or run `codegraph explore %s` (source, callers, "
545
630
  "blast radius in one call) or `codegraph callers %s` / `codegraph impact %s` from a shell; "
546
631
  "an omp subagent must use this CLI, not the MCP device, because omp's MCP device refuses "
547
632
  "concurrent writes. If you need literal text, re-run this search unchanged; it will pass - "
548
- "this nudge fires once per session." % (symbol, symbol, symbol))
633
+ "this nudge fires once per session."
634
+ % (graph_tool(), symbol, symbol, symbol, symbol))
635
+
636
+
637
+ def graph_advice(tool, inp, cwd, host=None):
638
+ """One non-blocking line for a symbol search the current graph answers, or
639
+ None. The refusal above is spent once per session; every later symbol
640
+ search gets this line beside its result instead, so the graph is named at
641
+ the moment the session reaches for grep. Fresh only: the stamp the index
642
+ worker writes must equal HEAD, because pointing at a graph that lags the
643
+ tree would send the session to a stale answer. A literal search, a repo
644
+ without its own index, `.no-graph` and `pretooluse-off` get nothing."""
645
+ symbol = searched_symbol(tool, inp) # first: a regex, and most calls end here
646
+ if not symbol or off("pretooluse-off"):
647
+ return None
648
+ base = root_for(cwd)
649
+ repo = index_dir(cwd, base) if base else None
650
+ if not repo:
651
+ return None
652
+ try:
653
+ stamped = _read(os.path.join(cache_dir(), index_slug(cwd, base)))
654
+ except OSError:
655
+ return None
656
+ head = git_head(repo)
657
+ if not head or stamped != head:
658
+ return None
659
+ return ("Graph: `%s` is a code symbol and this repo's code graph is current "
660
+ "(HEAD %s). For its definition, callers or blast radius call %s "
661
+ "with the query `%s` (or `codegraph callers %s` / `codegraph impact "
662
+ "%s` in a shell): one call returns the source and the call path "
663
+ "that this text search cannot."
664
+ % (symbol, head[:7], graph_tool(host), symbol, symbol, symbol))
549
665
 
550
666
 
551
667
  # The identical attempts a call gets before the loop guard refuses it, whatever
@@ -2637,7 +2753,7 @@ def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
2637
2753
  reason = retry_reason(tool, inp, session_id, agent)
2638
2754
  if reason:
2639
2755
  return _deny(session_id, "retry", reason, tool, inp, base)
2640
- if symbol := searched_identifier(tool, inp):
2756
+ if symbol := searched_symbol(tool, inp):
2641
2757
  slug = index_slug(cwd, base)
2642
2758
  if slug and first_nudge(session_id):
2643
2759
  note(session_id, "nudge", slug, id=call_id(tool, inp),
@@ -276,6 +276,9 @@ VERIFIED = re.compile(
276
276
  r"\b(tested|verified|i ran|ran the (?:tests?|suite|build|lint|checks?)|"
277
277
  r"doğruladım|test ettim|kontrol ettim|denetledim|doğrulandı|test edildi)\b",
278
278
  re.I)
279
+ # The DONE forms that say a check passed ("all tests pass", "testler yeşil"):
280
+ # such a claim rests on a pass even when the turn only edited prose.
281
+ TESTED_WORD = re.compile(r"test|check|suite", re.I)
279
282
  # an explicit admission that removes the lie: an unverified claim is allowed
280
283
  NEGATED = re.compile(
281
284
  r"doğrulanmadı|doğrulamadım|unverified|not verified|could ?n[o']t verify|"
@@ -3587,6 +3590,43 @@ def _changed_write(row):
3587
3590
  return "hash" in row and "changed" not in row
3588
3591
 
3589
3592
 
3593
+ # Prose a person reads and no program runs: a write tool's edit to one owes no
3594
+ # check and does not stale a pass (`_prose_edit`). The owner's rule (2026-10-07)
3595
+ # is "a non-code change needs no suite", and the stale branch on a NOTES.md
3596
+ # written after green moved no outcome (PREREGISTRATION-E8 F3: 17/25 against
3597
+ # 18/25). `.txt` is not here: requirements.txt is code by another name.
3598
+ PROSE_EXT = (".md", ".markdown", ".rst", ".adoc")
3599
+ # Markdown an agent loads as instructions is the product, not prose: these names
3600
+ # and anything under these directories stay a change like any code file.
3601
+ INSTRUCTION_NAMES = frozenset(("skill.md", "agents.md", "claude.md", "gemini.md",
3602
+ "copilot-instructions.md", "rules.md"))
3603
+ INSTRUCTION_DIRS = frozenset(("skills", "prompts", "templates", "agents",
3604
+ "commands", "output-styles", "hooks", ".claude",
3605
+ ".cursor", ".codex", ".omp", ".github"))
3606
+
3607
+
3608
+ def _prose_edit(row):
3609
+ """True for a write tool's `edit` row whose real target is prose
3610
+ (`PROSE_EXT`) and not an agent instruction file (`INSTRUCTION_NAMES`,
3611
+ `INSTRUCTION_DIRS`). The target is the realpath `note_tool` stored, so a
3612
+ `.md` link over a code file is judged by the file it writes; a row with no
3613
+ target is never prose. ponytail: a comment-only edit to a code file is
3614
+ still a change - telling it apart needs the file's text before and after,
3615
+ which the row does not carry."""
3616
+ target = row.get("target")
3617
+ if row.get("kind") != "edit" or not target:
3618
+ return False
3619
+ parts = str(target).replace("\\", "/").lower().split("/")
3620
+ return (parts[-1].endswith(PROSE_EXT) and parts[-1] not in INSTRUCTION_NAMES
3621
+ and not INSTRUCTION_DIRS.intersection(parts[:-1]))
3622
+
3623
+
3624
+ def _work_row(row):
3625
+ """True when the row is one step of work a check is owed for: a `WORK_KINDS`
3626
+ row that is not a prose edit (`_prose_edit`)."""
3627
+ return str(row.get("kind")) in WORK_KINDS and not _prose_edit(row)
3628
+
3629
+
3590
3630
  def _change_row(row):
3591
3631
  """True when this row is one the freshness fold counts as a change to the
3592
3632
  tree: a write tool's `edit` row, a shell call that wrote a file (`run`), or
@@ -3602,9 +3642,9 @@ def _change_row(row):
3602
3642
  check in one call (`sed -i ... && pytest`, `ruff format . && pytest`) records
3603
3643
  as the check, so it is not read as a change; a write whose target is not a
3604
3644
  redirect carries no captured state either (tezgah_gate.write_paths names
3605
- both ceilings)."""
3645
+ both ceilings). A prose edit (`_prose_edit`) is never one either."""
3606
3646
  kind = str(row.get("kind"))
3607
- return kind in ("edit", "run") and (
3647
+ return kind in ("edit", "run") and not _prose_edit(row) and (
3608
3648
  _changed_write(row)
3609
3649
  or (kind == "run" and format_write(str(row.get("detail") or ""))))
3610
3650
 
@@ -3663,7 +3703,7 @@ def _stale_paths(rows):
3663
3703
  with no file named at all."""
3664
3704
  names = []
3665
3705
  for row in rows[_last_pass(rows) + 1:]:
3666
- if row.get("kind") == "edit" and _changed_write(row):
3706
+ if row.get("kind") == "edit" and _change_row(row):
3667
3707
  name = str(row.get("detail") or "").strip()
3668
3708
  if len(name) > 80:
3669
3709
  name = "..." + name[-77:]
@@ -4441,7 +4481,7 @@ def _bookkeeping_turn(rows):
4441
4481
  cut it there, and the command after the cut is unread (review F1). Nor is
4442
4482
  one that holds a redaction marker: the marker can swallow the rest of a
4443
4483
  word and what was glued to it (`echo token=x;./regen.sh`, review N3)."""
4444
- steps = [r for r in rows if str(r.get("kind")) in WORK_KINDS]
4484
+ steps = [r for r in rows if _work_row(r)]
4445
4485
  return bool(steps) and all(
4446
4486
  r.get("kind") == "run" and not _change_row(r)
4447
4487
  and len(str(r.get("detail") or "")) < DETAIL_MAX
@@ -4456,7 +4496,7 @@ def _settled(rows):
4456
4496
  last = _last_pass(rows)
4457
4497
  if last < 0:
4458
4498
  return False
4459
- after = [r for r in rows[last + 1:] if str(r.get("kind")) in WORK_KINDS]
4499
+ after = [r for r in rows[last + 1:] if _work_row(r)]
4460
4500
  return not after or _bookkeeping_turn(after)
4461
4501
 
4462
4502
 
@@ -4528,7 +4568,6 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4528
4568
  return ("evidence tampered", TAMPERED)
4529
4569
  if (done or verified) and any(r.get(ORPHAN) for r in rows):
4530
4570
  return ("evidence tampered", ORPHANED)
4531
- ev = {str(entry.get("kind")) for entry in rows}
4532
4571
  # The trigger is the turn's own evidence, not its words. The claim vocabulary
4533
4572
  # below catches a claim-shaped reply; it missed the same unfounded state
4534
4573
  # stated as a description ("the parser is wired up now"), which is what E2
@@ -4541,7 +4580,12 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4541
4580
  # owes the check it never got - which is the `no verify_ok` floor below and
4542
4581
  # not the partial-failure branch above it, because an interruption is no
4543
4582
  # failure at all (the row carries no `exit`).
4544
- worked = ev & WORK_KINDS
4583
+ # A prose edit (`_prose_edit`) is no step a check is owed for - unless the
4584
+ # reply says something was tested or checked, a claim that rests on a pass.
4585
+ worked = {str(r.get("kind")) for r in rows if _work_row(r)}
4586
+ if not worked and any(_prose_edit(r) for r in rows) and (
4587
+ verified or any(TESTED_WORD.search(m.group(0)) for m in DONE.finditer(t))):
4588
+ worked = {"edit"}
4545
4589
  if not worked:
4546
4590
  # With no work, the words are the whole trigger, so a question or a
4547
4591
  # negation is not read as the claim it names (`asserted_claims`).
@@ -4599,7 +4643,7 @@ def _stop_block(text, session_id, rows=None, cwd=None, shape=True, fold=None):
4599
4643
  # allowed on all five Stop hosts (audit INT-01 / M-2).
4600
4644
  prior = _before_turn(events(session_id))
4601
4645
  refused = fold(
4602
- prior, {str(r.get("kind")) for r in prior} & WORK_KINDS, None,
4646
+ prior, {str(r.get("kind")) for r in prior if _work_row(r)}, None,
4603
4647
  "this session")
4604
4648
  if refused[0]:
4605
4649
  return refused
@@ -4624,7 +4668,7 @@ def _before_turn(rows):
4624
4668
  if row.get("kind") == TURN_KIND:
4625
4669
  pending = row
4626
4670
  continue
4627
- if pending is not None and str(row.get("kind")) in WORK_KINDS:
4671
+ if pending is not None and _work_row(row):
4628
4672
  out.append(pending)
4629
4673
  pending = None
4630
4674
  out.append(row)
@@ -4732,8 +4776,9 @@ def _evidence_block(rows, worked, external, where="this turn"):
4732
4776
  return ("stale evidence",
4733
4777
  "Stale evidence: the newest check that passed ran before %s %s "
4734
4778
  "written, so it verified an earlier revision of the tree than the "
4735
- "one this reply is about. Re-run the check over what is on disk "
4736
- "now and report its output, or mark the claim \"doğrulanmadı\". "
4779
+ "one this reply is about. Re-run the smallest check that covers "
4780
+ "the files written since (their own tests, not the whole suite) "
4781
+ "and report its output, or mark the claim \"doğrulanmadı\". "
4737
4782
  "A green run over the previous revision does not cover this one."
4738
4783
  % (shown or "a file this session wrote",
4739
4784
  "was" if len(names) <= 1 else "were"))
@@ -4753,8 +4798,10 @@ def _evidence_block(rows, worked, external, where="this turn"):
4753
4798
  "%s did work (edits or commands) and no check ran "
4754
4799
  "successfully in it (nothing recorded as verify_ok with a "
4755
4800
  "real result and an unmasked command), so nothing here supports "
4756
- "calling it done, complete or verified. Run the real check and "
4757
- "report its output, or mark the claim \"doğrulanmadı\". Do not "
4801
+ "calling it done, complete or verified. Run the smallest check "
4802
+ "that proves the change (the tests of what changed, not the "
4803
+ "whole suite) and report its output, or mark the claim "
4804
+ "\"doğrulanmadı\". Do not "
4758
4805
  "describe a check you did not run as if it ran."
4759
4806
  % where.capitalize())
4760
4807
  # Last, so none of the classes above loses its turn to it: every shape this