@r1z4x/tezgah 1.3.0 → 1.5.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 (53) hide show
  1. package/CHANGELOG.md +103 -1
  2. package/MANIFEST +3 -0
  3. package/agents/tezgah-reviewer.md +19 -10
  4. package/bin/tezgah-docs +3 -3
  5. package/bin/tezgah-doctor +75 -18
  6. package/bin/tezgah-route +5 -2
  7. package/bin/tezgah-setup +30 -18
  8. package/bin/tezgah-status +3 -1
  9. package/bin/tezgah-task +130 -37
  10. package/bin/tezgah-taste +74 -55
  11. package/bin/tezgah-triage +12 -7
  12. package/docs/architecture.md +4 -4
  13. package/docs/clarity-baseline.json +9 -9
  14. package/docs/contract.md +22 -12
  15. package/docs/environment.md +4 -1
  16. package/docs/evidence.md +71 -34
  17. package/docs/gate.md +21 -21
  18. package/docs/glossary.md +5 -5
  19. package/docs/hosts.md +8 -8
  20. package/docs/judge.md +93 -27
  21. package/docs/operations.md +69 -30
  22. package/docs/skills.md +2 -2
  23. package/docs/status-line.md +7 -7
  24. package/docs/vision.md +10 -5
  25. package/hooks/tezgah_agents.py +22 -11
  26. package/hooks/tezgah_attest.py +15 -22
  27. package/hooks/tezgah_context.py +84 -124
  28. package/hooks/tezgah_gate.py +26 -23
  29. package/hooks/tezgah_guard.py +9 -11
  30. package/hooks/tezgah_integrity.py +77 -178
  31. package/hooks/tezgah_judge.py +270 -122
  32. package/hooks/tezgah_lessons.py +18 -63
  33. package/hooks/tezgah_paths.py +183 -55
  34. package/hooks/tezgah_policy.py +35 -8
  35. package/hooks/tezgah_replay.py +57 -33
  36. package/hooks/tezgah_shapes.py +4 -4
  37. package/hooks/tezgah_skill_pick.py +6 -35
  38. package/hooks/tezgah_snapshot.py +25 -46
  39. package/hooks/tezgah_store.py +1268 -0
  40. package/hooks/tezgah_task.py +146 -40
  41. package/hooks/tezgah_taste.py +21 -18
  42. package/hooks/tezgah_taste_ledger.py +63 -88
  43. package/hooks/tezgah_update.py +6 -26
  44. package/hosts/codex/hook.py +5 -0
  45. package/hosts/cursor/hook.py +19 -31
  46. package/hosts/opencode/plugins/tezgah.js +309 -108
  47. package/package.json +1 -1
  48. package/skills/harness/SKILL.md +4 -0
  49. package/skills/plan-add/SKILL.md +23 -7
  50. package/skills/plan-add/render_table.py +3 -2
  51. package/skills/plan-status/SKILL.md +12 -4
  52. package/skills/plan-sync/SKILL.md +19 -9
  53. package/skills/tezgah-contract/SKILL.md +25 -12
@@ -7,16 +7,16 @@ The session block, the per-turn block and the per-turn digest
7
7
  ledger here, so they agree on what a lesson is and on its key. A module of its
8
8
  own because the gate must not import `tezgah_context` (its import cost, and the
9
9
  rule `hooks/tezgah_task.py::repo_root` states): this one imports only
10
- `tezgah_paths` at load, and `tezgah_gate` lazily, only for a line that names a
11
- gate rule as its enforcer.
10
+ `tezgah_paths` and `tezgah_store` at load, and `tezgah_gate` lazily, only for a
11
+ line that names a gate rule as its enforcer.
12
12
 
13
13
  Stdlib only."""
14
14
  import hashlib
15
- import json
16
15
  import os
17
16
  import re
18
17
 
19
- from tezgah_paths import cache_dir, off
18
+ import tezgah_store
19
+ from tezgah_paths import off
20
20
 
21
21
  # A line carries its metadata after its text, never in it: from the first
22
22
  # `|| check:` or `|| enforced_by:` clause outside a code span to the end of the
@@ -114,73 +114,28 @@ def is_ledger(path):
114
114
  and os.path.basename(os.path.dirname(path)) == ".tezgah")
115
115
 
116
116
 
117
- def taint_path(root):
118
- """The `lesson_tainted` index of the repository at `root`: the gate keeps
119
- each keyed row here as well as in the session's ledger, so the context of a
120
- later session reads one small file for the per-line label instead of every
121
- ledger on the machine (3173 files, 30 MB on the measuring machine,
122
- 2026-10-07)."""
123
- name = hashlib.sha1(os.path.realpath(root).encode("utf-8", "replace"))
124
- return os.path.join(cache_dir(), "lessons", name.hexdigest()[:16] + ".jsonl")
125
-
126
-
127
- def _index_rows(path):
128
- """The parseable keyed rows of an index file, oldest first; [] without one.
129
- A damaged line costs only itself."""
130
- rows = []
131
- try:
132
- with open(path, encoding="utf-8", errors="replace") as fh:
133
- for ln in fh:
134
- try:
135
- row = json.loads(ln)
136
- except ValueError:
137
- continue
138
- if isinstance(row, dict) and row.get("key"):
139
- rows.append(row)
140
- except OSError:
141
- pass
142
- return rows
117
+ def taint_root(root):
118
+ """The key of the repository at `root` in the `lesson_taint` store: the gate
119
+ keeps each keyed row there as well as in the session's ledger, so the
120
+ context of a later session reads one repository's rows for the per-line
121
+ label instead of every ledger on the machine (3173 files, 30 MB on the
122
+ measuring machine, 2026-10-07)."""
123
+ return hashlib.sha1(os.path.realpath(root).encode("utf-8", "replace")).hexdigest()[:16]
143
124
 
144
125
 
145
126
  def write_taint(root, keep, rows):
146
127
  """Rewrite the index of the repository at `root`: the rows it holds whose key
147
- is in `keep` (the ledger's lines plus the write's new ones), then `rows`. So
148
- the file never outgrows the ledger it labels. Best effort, like a ledger row.
149
- ponytail: two sessions rewriting one index in the same instant can lose one
150
- row (one label); the session ledger keeps its own copy of every row."""
151
- path = taint_path(root)
152
- kept = [r for r in _index_rows(path) if r["key"] in keep] + rows
153
- tmp = "%s.%d.tmp" % (path, os.getpid())
154
- try:
155
- os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
156
- fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
157
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
158
- fh.write("".join(json.dumps(r) + "\n" for r in kept))
159
- os.replace(tmp, path)
160
- except OSError:
161
- pass
162
-
163
-
164
- # The index as last read, per (path, mtime, size): the session block, the
165
- # per-turn block and the digest of one prompt each ask for it, and a hook
166
- # process serves one prompt.
167
- _TAINTED = {}
128
+ is in `keep` (the ledger's lines plus the write's new ones), then `rows`, in
129
+ one transaction. So the index never outgrows the ledger it labels. Best
130
+ effort, like a ledger row."""
131
+ tezgah_store.write_taint(taint_root(root), keep, rows)
168
132
 
169
133
 
170
134
  def tainted(root):
171
135
  """{key: source} for the lessons of the repository at `root` that a gate
172
- recorded as written in a turn that had read untrusted text (`taint_path`);
173
- {} when there is none. Read once while the file is unchanged."""
174
- path = taint_path(root)
175
- try:
176
- st = os.stat(path)
177
- except OSError:
178
- return {}
179
- key = (path, st.st_mtime_ns, st.st_size)
180
- if key not in _TAINTED:
181
- _TAINTED[key] = {str(r["key"]): str(r.get("source") or "")
182
- for r in _index_rows(path)}
183
- return _TAINTED[key]
136
+ recorded as written in a turn that had read untrusted text (`taint_root`);
137
+ {} when there is none."""
138
+ return tezgah_store.taint(taint_root(root))
184
139
 
185
140
 
186
141
  def lines(root, retired=None):
@@ -499,32 +499,129 @@ def have_typesafe_key():
499
499
  def have_judge_key():
500
500
  """True when tezgah's own judgement seam can ask anyone: the session's own
501
501
  CLI (`session_cli()`), else - when the `fallback` setting lets a third party
502
- answer - the TypeSafe env var or key file, then the OpenRouter fallback's own
503
- two channels (`OPENROUTER_API_KEY`, `~/.config/openrouter/key`), which are
504
- the channels `hooks/tezgah_judge.py`'s `providers()` picks between. This is
505
- what `bin/tezgah-triage`, `bin/tezgah-docs` and the skill picker spend. Never
506
- omp's login store: the seam does not open it.
502
+ answer - a chosen Jev carrier's credential (`jev_carriers()`) or the
503
+ OpenRouter chat fallback's own two channels (`OPENROUTER_API_KEY`,
504
+ `~/.config/openrouter/key`), which are the channels `hooks/tezgah_judge.py`'s
505
+ `providers()` picks between. This is what `bin/tezgah-triage`,
506
+ `bin/tezgah-docs` and the skill picker spend.
507
507
 
508
508
  Kept next to `have_typesafe_key()` because the two are asked together and a
509
509
  report that showed one answer under both questions was wrong in both
510
- directions - a key file alone (seam works, omp does not) and a login-store
511
- record alone (omp works, seam does not). A blank file is not a credential
512
- here either, so this stays in step with the seam's own `strip() or None`."""
510
+ directions - a key file alone serves the seam and not omp. A blank file is
511
+ not a credential here either, so this stays in step with the seam's own
512
+ `strip() or None`."""
513
513
  if session_cli():
514
514
  return True
515
515
  if fallback_policy() == "none":
516
516
  return False
517
- for env, home in (("TYPESAFE_API_KEY", ".config/typesafe/key"),
518
- ("OPENROUTER_API_KEY", ".config/openrouter/key")):
519
- if os.environ.get(env, "").strip():
520
- return True
517
+ return bool(openrouter_key() or any(s for _, s in jev_carriers()))
518
+
519
+
520
+ def key_from(envs, path):
521
+ """The first non-blank env var of `envs`, else the file at `path` (`~`
522
+ expanded), else None - stripped either way, because a newline inside an
523
+ Authorization header is an injection, not a typo. The file is the channel a
524
+ hook needs: it runs where `~/.zshenv` never exported the variable."""
525
+ for env in envs:
526
+ value = os.environ.get(env, "").strip()
527
+ if value:
528
+ return value
529
+ try:
530
+ with open(os.path.expanduser(path), encoding="utf-8") as fh:
531
+ return fh.read().strip() or None
532
+ except OSError:
533
+ return None
534
+
535
+
536
+ def omp_login_key(provider):
537
+ """The API key omp's login store holds for `provider` (`omp auth login`),
538
+ stripped, else None: the newest enabled `api_key` row's `data.key`, read
539
+ read-only. Any error - no store, another schema, a locked file - is None."""
540
+ import sqlite3 # deferred: see have_typesafe_key()
541
+ store = os.path.join(os.path.expanduser("~"), ".omp", "agent", "agent.db")
542
+ try:
543
+ conn = sqlite3.connect("file:%s?mode=ro" % store, uri=True)
521
544
  try:
522
- with open(os.path.join(HOME, home), encoding="utf-8") as fh:
523
- if fh.read().strip():
524
- return True
525
- except OSError:
526
- pass
527
- return False
545
+ rows = conn.execute(
546
+ "select data from auth_credentials where provider = ? and "
547
+ "disabled_cause is null order by id desc", (provider,)).fetchall()
548
+ finally:
549
+ conn.close()
550
+ except (sqlite3.Error, OSError):
551
+ return None
552
+ for (data,) in rows:
553
+ try:
554
+ value = json.loads(data).get("key")
555
+ except (TypeError, ValueError, AttributeError):
556
+ continue
557
+ if isinstance(value, str) and value.strip():
558
+ return value.strip()
559
+ return None
560
+
561
+
562
+ def typesafe_key():
563
+ """TypeSafe's credential: TYPESAFE_API_KEY, else `~/.config/typesafe/key`,
564
+ else the active omp login (`omp_login_key("typesafe")`), else None."""
565
+ return (key_from(("TYPESAFE_API_KEY",), "~/.config/typesafe/key")
566
+ or omp_login_key("typesafe"))
567
+
568
+
569
+ def openrouter_key():
570
+ """OpenRouter's credential: OPENROUTER_API_KEY, else its key file."""
571
+ return key_from(("OPENROUTER_API_KEY",), "~/.config/openrouter/key")
572
+
573
+
574
+ def cloudflare_account():
575
+ """The Workers AI account id: CLOUDFLARE_ACCOUNT_ID, else its file."""
576
+ return key_from(("CLOUDFLARE_ACCOUNT_ID",), "~/.config/cloudflare/account_id")
577
+
578
+
579
+ def cloudflare_token():
580
+ """The Workers AI token: JEV_CLOUDFLARE_API_TOKEN, else CLOUDFLARE_API_TOKEN,
581
+ else `~/.config/cloudflare/token`."""
582
+ return key_from(("JEV_CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN"),
583
+ "~/.config/cloudflare/token")
584
+
585
+
586
+ # The Jev carrier a user may choose (`jev_choice()`), and the order `auto`
587
+ # tries them in: TypeSafe direct, OpenRouter's System One endpoint, Cloudflare
588
+ # Workers AI, any System One-compatible URL (JEV_API_BASE_URL + JEV_API_KEY,
589
+ # the names the public jev-mcp server reads), then Cloudflare's own System One
590
+ # models Clef and Clef-flash on Workers AI (the same Cloudflare credential).
591
+ JEV_CHOICES = ("auto", "typesafe", "openrouter", "cloudflare", "compatible",
592
+ "clef", "clef-flash")
593
+
594
+
595
+ def jev_choice():
596
+ """TEZGAH_JEV_PROVIDER, else JEV_PROVIDER (jev-mcp's name), else config.json
597
+ `jev`, else `auto` - lowercased. An unknown value is kept, so it resolves to
598
+ no carrier instead of silently to another one."""
599
+ for env in ("TEZGAH_JEV_PROVIDER", "JEV_PROVIDER"):
600
+ value = os.environ.get(env, "").strip().lower()
601
+ if value:
602
+ return value
603
+ cfg = config()
604
+ value = cfg.get("jev") if isinstance(cfg, dict) else None
605
+ value = value.strip().lower() if isinstance(value, str) else ""
606
+ return value or "auto"
607
+
608
+
609
+ def jev_carriers():
610
+ """[(carrier, secret), ...] the chosen Jev carriers in the order the seam
611
+ asks them. `auto`: every carrier whose credential resolves. A named one: that
612
+ carrier alone, its secret None when it does not resolve - the seam records
613
+ why rather than substituting another carrier."""
614
+ compatible = (os.environ.get("JEV_API_KEY", "").strip() or None
615
+ if os.environ.get("JEV_API_BASE_URL", "").strip() else None)
616
+ workers_ai = cloudflare_token() if cloudflare_account() else None
617
+ found = [("typesafe", typesafe_key()), ("jev-openrouter", openrouter_key()),
618
+ ("jev-cloudflare", workers_ai), ("jev-compatible", compatible),
619
+ ("clef", workers_ai), ("clef-flash", workers_ai)]
620
+ choice = jev_choice()
621
+ if choice == "auto":
622
+ return [(name, secret) for name, secret in found if secret]
623
+ name = choice if choice in ("typesafe", "clef", "clef-flash") else "jev-" + choice
624
+ return [(name, dict(found).get(name))]
528
625
 
529
626
 
530
627
  # How far a model call may leave the session's own vendor (config.json
@@ -544,16 +641,39 @@ def fallback_policy():
544
641
  return value if value in FALLBACKS else "vendor"
545
642
 
546
643
 
644
+ # The session CLIs the judgement seam can ask, in detection order, each with
645
+ # the env vars its host sets in the processes it spawns. omp sets CLAUDECODE
646
+ # too, so it comes first. opencode sets OPENCODE=1 in its own process at start
647
+ # (`process.env.OPENCODE="1"` in the 1.18.31 binary), so its plugin's children
648
+ # and its shell tool inherit it. cursor-agent gives its shell tool CURSOR_AGENT=1
649
+ # and its hooks CURSOR_VERSION (`buildHookEnvironment` in the 2026.10.01
650
+ # bundle). codex gives its shell tool CODEX_THREAD_ID (codex-rs
651
+ # protocol/src/shell_environment.rs `populate_env`); its hooks get only codex's
652
+ # own process env (codex-rs hooks/src/registry.rs), so a codex hook names no
653
+ # session - `judge_cli` picks it there.
654
+ SESSION_CLIS = (("omp", ("OMPCODE",)), ("claude", ("CLAUDECODE",)),
655
+ ("opencode", ("OPENCODE",)), ("cursor", ("CURSOR_AGENT", "CURSOR_VERSION")),
656
+ ("codex", ("CODEX_THREAD_ID",)))
657
+
658
+
547
659
  def session_cli():
548
- """`omp` or `claude` - the CLI of the host this process runs under, when it
549
- is installed - else None. omp sets CLAUDECODE too, so OMPCODE is read first,
550
- and an omp session whose binary is missing is None rather than Claude: the
551
- session's credential is omp's, not whatever `claude` is logged in as."""
552
- if os.environ.get("OMPCODE"):
553
- return "omp" if omp_bin() else None
554
- if os.environ.get("CLAUDECODE"):
555
- return "claude" if claude_bin() else None
556
- return None
660
+ """The session CLI the judgement seam asks - `omp`, `claude`, `opencode`,
661
+ `cursor` or `codex` - else None.
662
+
663
+ TEZGAH_JUDGE_CLI, else config.json `judge_cli`, picks one by name; `auto`
664
+ (the default, and any other value) reads the host this process runs under:
665
+ the first CLI in SESSION_CLIS whose marker is set. Either way the answer is
666
+ None when that CLI's binary is missing - an omp session is not handed to
667
+ whatever `claude` is logged in as, and a picked CLI has no stand-in."""
668
+ bins = {"omp": omp_bin, "claude": claude_bin, "opencode": opencode_bin,
669
+ "cursor": cursor_bin, "codex": codex_bin}
670
+ cfg = config()
671
+ pick = (os.environ.get("TEZGAH_JUDGE_CLI", "").strip()
672
+ or (cfg.get("judge_cli") if isinstance(cfg, dict) else None))
673
+ if not isinstance(pick, str) or pick not in bins:
674
+ pick = next((name for name, markers in SESSION_CLIS
675
+ if any(os.environ.get(m) for m in markers)), None)
676
+ return pick if pick and bins[pick]() else None
557
677
 
558
678
 
559
679
  def orx_bin():
@@ -584,6 +704,24 @@ def claude_bin():
584
704
  return which_user(os.environ.get("TEZGAH_CLAUDE_BIN") or "claude")
585
705
 
586
706
 
707
+ def opencode_bin():
708
+ """The opencode CLI (TEZGAH_OPENCODE_BIN, else `opencode`), or None - the
709
+ lookup `omp_bin()` makes."""
710
+ return which_user(os.environ.get("TEZGAH_OPENCODE_BIN") or "opencode")
711
+
712
+
713
+ def cursor_bin():
714
+ """Cursor's agent CLI (TEZGAH_CURSOR_BIN, else `cursor-agent`), or None -
715
+ the lookup `omp_bin()` makes."""
716
+ return which_user(os.environ.get("TEZGAH_CURSOR_BIN") or "cursor-agent")
717
+
718
+
719
+ def codex_bin():
720
+ """The Codex CLI (TEZGAH_CODEX_BIN, else `codex`), or None - the lookup
721
+ `omp_bin()` makes."""
722
+ return which_user(os.environ.get("TEZGAH_CODEX_BIN") or "codex")
723
+
724
+
587
725
  # The values `reply_lang` (config.json) takes. The value itself is the switch:
588
726
  # `tr` (the default, and what an install without the key reads) asks for Turkish
589
727
  # replies, `en` for English ones, and `any` drops the Stop language check.
@@ -612,50 +750,40 @@ def reply_lang():
612
750
  # `authorized` row, the stand-down text) - the control rule refuses the routes
613
751
  # the gate sees, and an interpreter writing them is SECURITY.md's residual.
614
752
  STAND_DOWN = "tezgah-setup --uninstall --full\n"
615
- _LATCH = {"ledger": None, "since": None, "auth": None}
753
+ _LATCH = {"ledger": None, "since": None}
616
754
 
617
755
 
618
756
  def latch(ledger):
619
757
  """Bind this process's `off()` to one session's ledger path (None unbinds)."""
620
- _LATCH.update(ledger=ledger, since=None, auth=None)
758
+ _LATCH.update(ledger=ledger, since=None)
621
759
 
622
760
 
623
761
  def _first_ts(ledger):
762
+ import tezgah_store # deferred: an unbound process never opens the ledger
624
763
  try:
625
- with open(ledger, "rb") as fh:
626
- ts = json.loads(fh.readline()).get("ts")
627
- except (OSError, ValueError, AttributeError):
764
+ ts = json.loads(tezgah_store.evidence_first(ledger) or "null").get("ts")
765
+ except (*tezgah_store.ERRORS, ValueError, AttributeError):
628
766
  return None
629
767
  return ts if isinstance(ts, (int, float)) else None
630
768
 
631
769
 
632
770
  def _authorized(ledger):
633
- """The switch names this session's `authorized` rows carry, memoized on
634
- the ledger's size: read only when a switch is newer than the first row."""
635
- try:
636
- size = os.path.getsize(ledger)
637
- except OSError:
638
- return frozenset()
639
- memo = _LATCH["auth"]
640
- if memo and memo[0] == size:
641
- return memo[1]
771
+ """The switch names this session's `authorized` rows carry: read only when
772
+ a switch is newer than the first row."""
773
+ import tezgah_store
642
774
  names = set()
643
775
  try:
644
- with open(ledger, encoding="utf-8", errors="replace") as fh:
645
- for line in fh:
646
- if '"authorized"' not in line:
647
- continue
648
- try:
649
- row = json.loads(line)
650
- except ValueError:
651
- continue
652
- if row.get("kind") == "authorized" and isinstance(
653
- row.get("authorized"), list):
654
- names.update(n for n in row["authorized"] if isinstance(n, str))
655
- except OSError:
776
+ found = tezgah_store.evidence_rows(ledger, kind="authorized")
777
+ except tezgah_store.ERRORS:
656
778
  return frozenset()
657
- _LATCH["auth"] = (size, frozenset(names))
658
- return _LATCH["auth"][1]
779
+ for text in found:
780
+ try:
781
+ row = json.loads(text)
782
+ except ValueError:
783
+ continue
784
+ if isinstance(row, dict) and isinstance(row.get("authorized"), list):
785
+ names.update(n for n in row["authorized"] if isinstance(n, str))
786
+ return frozenset(names)
659
787
 
660
788
 
661
789
  def _honored(path, name):
@@ -237,7 +237,15 @@ rewrite it plainly.
237
237
  on a merge. Review first with a FRESH reviewer (never my own read of my own
238
238
  code), run the full suite, and if the review is clean and every test passes,
239
239
  merge, move the plan to `.tezgah/plans/done/`, and report the outcome. Bring it back
240
- to the user only on a critical or high-severity finding, or a failing test.
240
+ to the user only on a critical or major finding left after round two, or a
241
+ failing test. One review per plan, over its whole diff in the verification
242
+ phase; at most two rounds - only a confirmed critical or major finding opens
243
+ round two, a minor or suggestion one is recorded fix-later, and after round two
244
+ what is left goes to the user or is recorded fix-later, never a third round.
245
+ A round spawns its reviewers in ONE message, one per dimension (correctness
246
+ and contract, security, tests and performance) or file group; a diff under 300
247
+ changed lines gets one reviewer. Per-task work gets tests, not a reviewer, and
248
+ `tezgah-task review` refuses a third round.
241
249
 
242
250
  **Attribution: none, anywhere, ever.** Nothing you persist or publish may name
243
251
  the assistant, model, vendor or "AI" as author, co-author, generator or helper -
@@ -283,9 +291,11 @@ independent work remains. Fan out only when
283
291
  the subtasks share no mutable file and no interface: if two of them would edit
284
292
  the same file, or one's answer decides the other's, keep them in one context or
285
293
  sequence them. The shared artifact is the coordination channel, not chatter -
286
- naming a lead coordinates nothing by itself. If the work
287
- cannot be split - one file, one bounded change, a strictly serial chain - do it
288
- directly. Never spawn a subagent whose briefing is bigger than the work.
294
+ naming a lead coordinates nothing by itself. Only a single item - one file, one
295
+ bounded change, a strictly serial chain - is done directly; a list of
296
+ independent items is never worked one by one on the main thread, however small
297
+ each item is, and that holds at every phase: a plan's tasks, its acceptance
298
+ commands and the sources of a survey fan out the same way.
289
299
  A slice that needs its own checkout: inside Orca (the session-start `Orca:`
290
300
  line) create it with `orca worktree create --name <slug> --parent-worktree
291
301
  active --json` and track it with `orca worktree ps --json` or `tezgah-status
@@ -330,7 +340,8 @@ guards against.
330
340
 
331
341
  ### The gate, for anything either tier produces
332
342
  Read the diff yourself. Then tests, lint and type check must pass, and anything
333
- non-trivial gets an independent review before it lands. A delegate's output is
343
+ non-trivial gets an independent review before it lands - once per plan, not
344
+ per task or commit. A delegate's output is
334
345
  a draft until the router has evidence; "the agent said it works" is not
335
346
  evidence.
336
347
 
@@ -897,8 +908,14 @@ project root and never `git add -f` anything under `.tezgah/`. Off:
897
908
  **Parallel by default: fan out independent items.** A request with two or
898
909
  more independent items - a numbered or bulleted list, several files or areas, a
899
910
  todo list whose entries share no file and no interface - is delegated without
900
- being asked: one subagent per item, all spawned in ONE message, while the main
901
- thread decomposes, integrates, verifies and reports. Every writing slice gets
911
+ being asked: count the independent items, spawn one subagent per item in ONE
912
+ message, and keep the main thread for decomposing, integrating, verifying and
913
+ reporting. It holds at every phase, not only the first message: a plan's tasks,
914
+ its acceptance commands and the sources of a survey are items too, and checks
915
+ that share no state are tool calls in one message, never a queue (one suite run
916
+ at a time: two runs of one suite storm the CPU and race on its temp state).
917
+ No item is too small to delegate while another runs beside it; only a single
918
+ item is done inline. Every writing slice gets
902
919
  its own branch and checkout - inside Orca `orca worktree create --name <slug>
903
920
  --parent-worktree active --json`, else `git worktree add -b <branch> <absolute
904
921
  path>`; its brief names every read, edit and shell `cwd` as an absolute path
@@ -907,7 +924,8 @@ not grow. Read-only slices need no worktree. Pick each agent by task class from
907
924
  the specialists line; `{ROUTE_BIN}` names the tier for code work. Serialize
908
925
  only a real dependency - one slice's output is another's input, or two slices
909
926
  edit one file - and say which. Never wait idle on a spawned slice while
910
- independent work remains. Off: `orchestrate-off`.
927
+ independent work remains. A subagent does its own slice and never fans out
928
+ again. Off: `orchestrate-off`.
911
929
 
912
930
  **Kill switches:** each one removes its own rule from this text, not just the
913
931
  status mark. `~/.config/tezgah/`: `exec-mode.off`, `orchestrate-off`,
@@ -928,6 +946,15 @@ The ponytail intensity level is not a switch: `tezgah-pony lite|full|ultra`.
928
946
  # CORE_RULES labels in hooks/tezgah_context.py.
929
947
  CONDITIONAL_KEYS = ("spec", "consult", "research", "product", "graph", "fanout")
930
948
 
949
+ # The fan-out rule's action, which the one-line repeat of an armed paragraph
950
+ # (hooks/tezgah_context.armed_again) carries: a bare "Armed again" named a rule
951
+ # the session had to recall, and the second list of a session was worked one
952
+ # item at a time. It is the paragraph's own clause, cut to one sentence.
953
+ FANOUT_ACTION = ("count the independent items, spawn one subagent per item in "
954
+ "ONE message (each writing slice in its own worktree, absolute "
955
+ "paths), and keep the main thread for integrating and verifying;"
956
+ " a subagent does its own slice and never fans out again.")
957
+
931
958
  # The always-on replacement for the conditional paragraphs: one line each so a
932
959
  # host without a per-turn hook still knows the rule exists and where the full
933
960
  # text lives.