@r1z4x/tezgah 1.8.3 → 1.9.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.
package/bin/tezgah-task CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """tezgah-task - the active task: one plan's phase and its path allowlist.
3
3
 
4
- tezgah-task start <NNN|path> --phase P [--allow GLOB ... | --any-path] activate a plan
4
+ tezgah-task start <NNN|path> --phase P [--allow GLOB ... | --any-path] [--branch B] activate a plan; --branch binds the worktree on B to it
5
5
  tezgah-task phase P [--any-path] change the active task's phase
6
6
  tezgah-task allow GLOB ... | --any-path --why REASON replace the allowlist, recording why
7
7
  tezgah-task stop deactivate (clear phase)
@@ -11,6 +11,7 @@
11
11
  tezgah-task tick <NNN|path> <n> [--undo] tick (or untick) Acceptance item n, counted from 1
12
12
  tezgah-task note <NNN|path> state|next "<text>" replace the body of `## State` (the newest one) or `## Next`
13
13
  tezgah-task spike <NNN|path> "<answer>" record the spike's answer (`spike_recorded:`)
14
+ tezgah-task unmet <NNN|path> <n> "<reason, who accepted it>" annotate Acceptance item n with `UNMET:`, the form close accepts
14
15
  tezgah-task slice open --id ID [--kind agent|job] [--host H] [--agent A] [--brief TEXT|--brief-file F] [--parent P] [--cwd PATH]
15
16
  record a delegated slice (a re-open counts one more attempt)
16
17
  tezgah-task slice done ID | slice stop ID [--class C] close a slice record
@@ -19,6 +20,7 @@
19
20
  tezgah-task slice stats [--window DAYS] [--json] counts per status, failure class and host, and the RSI lever candidates
20
21
  tezgah-task slice board [--parent P] the open sibling slices: what each owns, its worktree, branch and last commit
21
22
  tezgah-task slice merge-plan [--base REF] the done slice branches in merge order, with their forecast conflicts
23
+ tezgah-task sweep the last session's leftovers: pending slices, landed worktrees and branches, an orphan phase
22
24
 
23
25
  The record is the plan file itself: `phase:` activates it, `allowed_paths:` is
24
26
  the scope a write has to stay inside, and `start` clears every other plan's
@@ -37,7 +39,7 @@ and this CLI is the record's only writer: the gate refuses a write to the plan
37
39
  file, because the checks below are made here and a retyped frontmatter would
38
40
  skip them. `phase implementation` (or `verification`) is what lets a write
39
41
  through, so the phase changes when the work reaches that point and not before.
40
- `tick`, `note` and `spike` are the record's body writers for the same reason:
42
+ `tick`, `note`, `spike` and `unmet` are the record's body writers for the same reason:
41
43
  they write an Acceptance box, the State/Next text or the spike's answer on any
42
44
  open plan, active or not, keep the rest of the frontmatter but `updated:`, and
43
45
  commit nothing.
@@ -134,7 +136,7 @@ import time
134
136
  HERE = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
135
137
  sys.path.insert(0, os.path.join(HERE, "hooks"))
136
138
  import tezgah_task as tt # noqa: E402
137
- from tezgah_paths import project_top, say # noqa: E402
139
+ from tezgah_paths import project_top, say, work_tree # noqa: E402
138
140
 
139
141
 
140
142
  def repo_root(path):
@@ -198,6 +200,22 @@ def active_here(root):
198
200
  return tt.active(root, root, held=True)
199
201
 
200
202
 
203
+ def own_task(root):
204
+ """(task, problem) for a command that writes the active plan's record. In a
205
+ linked worktree on a branch that binds a plan - `plan/NNN-...` or the plan's
206
+ declared `branch:` - that is plan NNN or a refusal, never the main
207
+ checkout's first phased plan: `allow` there replaced another plan's
208
+ allowlist (plan 102). The gate keeps that fallback, which holds the
209
+ worktree's writes; only the record write is refused."""
210
+ task = active_here(root)
211
+ ident = tt.plan_of_branch(tt.worktree_branch(root), main_root(root))
212
+ if ident is not None and (not task or task["id"] != ident):
213
+ return None, ("this worktree is plan %s's and plan %s is not the active task "
214
+ "here: start it with `tezgah-task start %s --phase P`; no other "
215
+ "plan's record moves from this worktree" % (ident, ident, ident))
216
+ return task, None
217
+
218
+
201
219
  def problems(items):
202
220
  for problem in items:
203
221
  print("FAIL %s" % problem)
@@ -271,7 +289,7 @@ def checkpoint_state(value, path):
271
289
  # a plan held by its own worktree waits on that checkout's commit, the
272
290
  # one the gate reads (tezgah_gate.checkpoint_reason)
273
291
  holder = holder_of(path)
274
- root = holder or repo_root(os.path.dirname(path) or ".")
292
+ root = holder or work_tree(repo_root(os.path.dirname(path) or "."))
275
293
  head = git_out(root, "rev-parse", "HEAD")
276
294
  listed = git_out(root, "status", "--porcelain")
277
295
  if not head or head != sha or not listed:
@@ -451,7 +469,7 @@ def verification_steps(text, path):
451
469
  print(" - %s" % item)
452
470
  # the deferred tier: never inside a turn, run once here at the merge step
453
471
  deferred = [cmd for rel, cmd in MERGE_CHECKS
454
- if os.path.isfile(os.path.join(root, rel))]
472
+ if os.path.isfile(os.path.join(work_tree(where), rel))]
455
473
  if deferred:
456
474
  print(" the merge step's one run of the deferred checks: %s"
457
475
  % ", ".join("`%s`" % c for c in deferred))
@@ -518,6 +536,7 @@ def cmd_start(args):
518
536
  root = main_root(repo_root(os.getcwd()))
519
537
  path, problem = open_plan(args[0])
520
538
  phase = None
539
+ branch = None
521
540
  allow = []
522
541
  any_path = False
523
542
  rest = args[1:]
@@ -529,6 +548,12 @@ def cmd_start(args):
529
548
  phase = rest[i + 1]
530
549
  i += 2
531
550
  continue
551
+ if rest[i] == "--branch":
552
+ if i + 1 >= len(rest) or rest[i + 1].startswith("--"):
553
+ return misuse("--branch needs the branch name to bind the plan to")
554
+ branch = rest[i + 1]
555
+ i += 2
556
+ continue
532
557
  if rest[i] == ANY_PATH:
533
558
  any_path = True
534
559
  i += 1
@@ -553,6 +578,8 @@ def cmd_start(args):
553
578
  return problems([problem])
554
579
  text = read(path)
555
580
  fields = {"phase": phase, "updated": today()}
581
+ if branch:
582
+ fields["branch"] = branch # binds the worktree on it (plan 105)
556
583
  if allow or any_path:
557
584
  fields.update(allowed_paths=allow, allowed_from="--allow" if allow else ANY_PATH)
558
585
  if tt.frontmatter(text).get("allowed_why"):
@@ -567,9 +594,11 @@ def cmd_start(args):
567
594
  return problems(bad)
568
595
  holders = tt.plan_holders(root)
569
596
  # the plan's own worktree, when one holds it: the gate reads the checkpoint
570
- # against that checkout's HEAD and tree, so it is recorded from there
571
- here = holders.get(tt._plan_id(tt.frontmatter(text), os.path.basename(path)))
572
- fields.update(checkpoint_fields(here or root, phase))
597
+ # against that checkout's HEAD and tree, so it is recorded from there - the
598
+ # one on the branch this start binds, before the record names it
599
+ here = (holders.get(tt._plan_id(tt.frontmatter(text), os.path.basename(path)))
600
+ or branch and tt._worktree_branches(root).get(branch))
601
+ fields.update(checkpoint_fields(here or work_tree(root), phase))
573
602
  for other in plans(root):
574
603
  if other == path:
575
604
  continue
@@ -602,9 +631,9 @@ def cmd_phase(args):
602
631
  if phase not in tt.TASK_PHASES:
603
632
  return problems([bad_phase(phase)])
604
633
  root = repo_root(os.getcwd())
605
- task = active_here(root)
606
- if not task:
607
- return problems(["no active task: start one with "
634
+ task, bad = own_task(root)
635
+ if bad or not task:
636
+ return problems([bad or "no active task: start one with "
608
637
  "`tezgah-task start <id> --phase %s`" % phase])
609
638
  text = read(task["path"])
610
639
  fields = {"phase": phase, "updated": today()}
@@ -614,9 +643,13 @@ def cmd_phase(args):
614
643
  fields["allowed_why"] = ""
615
644
  granted = (any_path or tt.frontmatter(text).get("allowed_from")
616
645
  == ANY_PATH)
617
- bad = (scope_problem(phase, task["allowed_paths"], granted)
618
- or phase_problems(phase, text, task["path"])
619
- or (held_overlap(root, task["id"], []) if any_path else []))
646
+ # one pre-flight list, not one refusal per attempt: a plan that owes the
647
+ # scope, the Acceptance and the spike learns all three from one run
648
+ # (plan 108)
649
+ bad = list(scope_problem(phase, task["allowed_paths"], granted) or [])
650
+ bad += phase_problems(phase, text, task["path"])
651
+ if any_path:
652
+ bad += held_overlap(root, task["id"], [])
620
653
  if bad:
621
654
  return problems(bad)
622
655
  where = task.get("worktree") or tt.plan_holders(main_root(root)).get(task["id"])
@@ -645,9 +678,9 @@ def cmd_allow(args):
645
678
  if not why:
646
679
  return misuse("allow takes --why \"<what in the plan reaches these paths>\"")
647
680
  root = repo_root(os.getcwd())
648
- task = active_here(root)
649
- if not task:
650
- return problems(["no active task to widen: start one with "
681
+ task, bad = own_task(root)
682
+ if bad or not task:
683
+ return problems([bad or "no active task to widen: start one with "
651
684
  "`tezgah-task start <id> --phase P --allow GLOB`"])
652
685
  bad = held_overlap(root, task["id"], args)
653
686
  if bad:
@@ -664,12 +697,58 @@ def cmd_allow(args):
664
697
  return show(task["path"])
665
698
 
666
699
 
700
+ def cmd_unmet(args):
701
+ """Annotate one Acceptance item with `UNMET: <reason, who accepted it>` - the
702
+ form `close NNN done` accepts in place of a ticked box, and until now the one
703
+ the CLI could not write: the gate refuses a write to the record, so an item
704
+ the owner accepted as not met could only be annotated by hand and the plan
705
+ could not close (plan 110). The record's fifth body writer beside `tick`,
706
+ `note`, `spike` and `sweep`; it commits nothing."""
707
+ if len(args) != 3:
708
+ return misuse('unmet takes <NNN|path> <n> "<reason, who accepted it>"')
709
+ ref, number, reason = args
710
+ if not number.isdigit() or int(number) < 1:
711
+ return misuse("unmet takes the item's number, counted from 1")
712
+ reason = " ".join(reason.split())
713
+ if not reason:
714
+ return misuse("unmet takes the reason, and who accepted it")
715
+ # the same resolver `tick` and `note` take: `plan_path` accepts any existing
716
+ # file, which let `unmet README.md 1 x` rewrite a README, and it resolved the
717
+ # wrong checkout from a linked worktree (review of plan 110, F2)
718
+ path, problem = open_plan(ref)
719
+ if problem:
720
+ return problems([problem])
721
+ n = int(number)
722
+ items = tt.acceptance_items(read(path))
723
+ if n > len(items):
724
+ return problems(["%s has %d Acceptance item(s), so item %d names none"
725
+ % (path, len(items), n)])
726
+ if items[n - 1]["checked"]:
727
+ return problems(["%s item %d is ticked already: `UNMET:` is for an item the "
728
+ "owner accepted as not met, so untick it first "
729
+ "(`tezgah-task tick %s %d --undo`)" % (path, n, ref, n)])
730
+ if tt._UNMET.search(items[n - 1]["text"]):
731
+ return problems(["%s item %d carries an `UNMET:` line already (%s)"
732
+ % (path, n, items[n - 1]["text"][:64])])
733
+ try:
734
+ item = tt.set_unmet(path, n, reason)
735
+ except OSError as exc:
736
+ return problems(["cannot write %s: %s" % (path, exc)])
737
+ if item is None:
738
+ return problems(["%s has no Acceptance item %d" % (path, n)])
739
+ bad = write_fields(path, updated=today())
740
+ if bad:
741
+ return problems(bad)
742
+ print("%s: item %d -> UNMET: %s" % (path, n, reason))
743
+ return 0
744
+
745
+
667
746
  def cmd_stop(args):
668
747
  if args:
669
748
  return misuse("stop takes no arguments")
670
- task = active_here(repo_root(os.getcwd()))
671
- if not task:
672
- return problems(["no active task to stop"])
749
+ task, bad = own_task(repo_root(os.getcwd()))
750
+ if bad or not task:
751
+ return problems([bad or "no active task to stop"])
673
752
  bad = write_fields(task["path"], phase="", updated=today())
674
753
  if bad:
675
754
  return problems(bad)
@@ -955,8 +1034,18 @@ def cmd_close(args):
955
1034
  print(line)
956
1035
  # the checkout this runs in stays, even when it is a landed plan worktree:
957
1036
  # removing it would pull the session's own cwd from under it
958
- for line in prune_worktrees(root, keep=repo_root(os.getcwd())):
1037
+ pruning = prune_worktrees(root, keep=repo_root(os.getcwd()))
1038
+ for line in pruning:
959
1039
  print(line)
1040
+ kept = [line for line in pruning
1041
+ if line.startswith(("worktree kept:", "branch kept:"))]
1042
+ if kept:
1043
+ # the reason a checkout stayed goes on the record: printed once, the
1044
+ # next session re-derived the same decision. Written after the move -
1045
+ # the record is in done/ now - and, like the other body writers, this
1046
+ # one commits nothing (plan 108)
1047
+ for line in write_fields(target, worktrees_kept=kept, updated=today()):
1048
+ print(line)
960
1049
  return 0 if committed else 1
961
1050
 
962
1051
 
@@ -1012,7 +1101,41 @@ def commit_close(root, name, message):
1012
1101
  # a test holds a cache.
1013
1102
  REGENERABLE = frozenset(("__pycache__", ".pytest_cache", ".ruff_cache",
1014
1103
  ".mypy_cache", "node_modules", ".venv", "venv",
1015
- ".DS_Store"))
1104
+ ".DS_Store", ".codegraph"))
1105
+
1106
+
1107
+ def _only_copy(path, rel):
1108
+ """The first file under the ignored entry `rel` of worktree `path` that
1109
+ nothing regenerates, or None. tezgah's own agent files (`tezgah-*`, carrying
1110
+ the managed marker) are written again at session start (plan 102); a file
1111
+ beside them is the user's."""
1112
+ from tezgah_agents import MARKER
1113
+ full = os.path.join(path, rel)
1114
+ unread = [] # a directory the walk cannot list holds what it cannot tell
1115
+ walk = ([(os.path.dirname(full), [], [os.path.basename(full)])]
1116
+ if not os.path.isdir(full) or os.path.islink(full)
1117
+ else os.walk(full, onerror=lambda exc: unread.append(exc.filename)))
1118
+ # lazily, stopping at the first file nobody regenerates: an ignored build
1119
+ # tree is walked only as far as its first entry (review of plan 102)
1120
+ for d, dirs, names in walk:
1121
+ dirs.sort()
1122
+ # a link to a directory is a file os.walk does not descend: keep it
1123
+ for name in sorted(names) + [x for x in dirs if os.path.islink(os.path.join(d, x))]:
1124
+ entry = os.path.join(d, name)
1125
+ managed = False
1126
+ # the type before any open: a FIFO blocks open() until a writer comes
1127
+ if name.startswith("tezgah-") and os.path.isfile(entry) \
1128
+ and not os.path.islink(entry):
1129
+ try:
1130
+ with open(entry, encoding="utf-8", errors="replace") as fh:
1131
+ managed = MARKER in fh.read(4096)
1132
+ except OSError:
1133
+ pass
1134
+ if not managed:
1135
+ return os.path.relpath(entry, path)
1136
+ if unread:
1137
+ return os.path.relpath(unread[0], path)
1138
+ return os.path.relpath(unread[0], path) if unread else None
1016
1139
 
1017
1140
 
1018
1141
  def _landed(root, branch, base):
@@ -1041,10 +1164,16 @@ def _keeps(root, path, branch, bases, agents):
1041
1164
  return "uncommitted changes"
1042
1165
  for line in (git_out(path, "status", "--porcelain", "-unormal", "--ignored") or "").splitlines():
1043
1166
  rel = line[3:].strip('"').rstrip("/")
1044
- if rel != ".tezgah" and not REGENERABLE & set(rel.split("/")):
1045
- return "ignored file %s may be the only copy" % rel
1167
+ if rel == ".tezgah" or REGENERABLE & set(rel.split("/")):
1168
+ continue
1169
+ kept = _only_copy(path, rel)
1170
+ if kept:
1171
+ return "ignored file %s may be the only copy" % kept
1046
1172
  ws = os.path.join(path, ".tezgah")
1047
- if os.path.isdir(ws):
1173
+ # a link to the repository's one workspace (plans 098 and 100) is no data of
1174
+ # the worktree's own: removing the worktree drops only the link
1175
+ if os.path.isdir(ws) and os.path.realpath(ws) != os.path.realpath(
1176
+ os.path.join(root, ".tezgah")):
1048
1177
  if set(os.listdir(ws)) - {".git"}:
1049
1178
  return "its .tezgah holds data"
1050
1179
  if os.path.isdir(os.path.join(ws, ".git")) and git_out(ws, "rev-parse", "-q", "--verify", "HEAD"):
@@ -1081,7 +1210,10 @@ def drop_branch(root, branch):
1081
1210
  return None
1082
1211
  done = subprocess.run(["git", "-C", root, "branch", "-D", branch],
1083
1212
  capture_output=True, text=True)
1084
- if done.returncode == 0:
1213
+ # Orca deletes the branch of a checkout it removes: one gone by the time
1214
+ # `-D` ran was deleted, not kept (review of plan 104)
1215
+ if done.returncode == 0 or not git_out(root, "rev-parse", "-q", "--verify",
1216
+ "refs/heads/" + branch):
1085
1217
  return None
1086
1218
  return ("branch kept: %s (`git branch -D %s` refused: %s)"
1087
1219
  % (branch, branch, ((done.stderr or "").strip().splitlines() or ["?"])[0]))
@@ -1127,10 +1259,18 @@ def prune_worktrees(root, keep=None):
1127
1259
  gone = tezgah_orca.run(["worktree", "rm", "--worktree", "path:" + path],
1128
1260
  timeout=60) is not None
1129
1261
  how = "orca worktree rm --worktree path:%s" % path
1262
+ # Orca answers before the directory is gone, and git refuses to
1263
+ # drop a branch a checkout on disk still holds (plan 104)
1264
+ deadline = time.monotonic() + 10
1265
+ while gone and os.path.exists(path) and time.monotonic() < deadline:
1266
+ time.sleep(0.1)
1130
1267
  else:
1131
1268
  gone = git_out(root, "worktree", "remove", path) is not None
1132
1269
  how = "git worktree remove %s" % path
1133
1270
  why = None if gone else "`%s` failed; run it and report its error" % how
1271
+ if gone and os.path.exists(path):
1272
+ why = ("`%s` answered but the checkout is still on disk after 10 s; "
1273
+ "run close again" % how)
1134
1274
  out.append("worktree %s: %s (%s)" % ("kept" if why else "removed", path,
1135
1275
  why or branch))
1136
1276
  if why is None:
@@ -1601,6 +1741,74 @@ def cmd_slice(args):
1601
1741
  return misuse(usage)
1602
1742
 
1603
1743
 
1744
+ def orphan_phases(root):
1745
+ """[(path, id, branch)] for phased open plans whose checkout branch is gone.
1746
+ Plan-add declares a branch before creating it, so absence alone is not an
1747
+ orphan: checkpoint, landed or reviewed_sha must show the phase ran in a
1748
+ checkout. A failed git query proves nothing. Query the current refs rather
1749
+ than this sweep's deletions: a checkout may have been removed by hand."""
1750
+ listed = git_out(root, "for-each-ref", "--format=%(refname)", "refs/heads")
1751
+ if listed is None:
1752
+ return []
1753
+ branches = set(listed.splitlines())
1754
+ out = []
1755
+ for path in plans(root):
1756
+ fields = tt.frontmatter(read(path))
1757
+ phase, branch = fields.get("phase") or "", fields.get("branch") or ""
1758
+ if not phase or not branch or not any(fields.get(key) for key in
1759
+ ("checkpoint", "landed", "reviewed_sha")):
1760
+ continue
1761
+ if "refs/heads/" + branch not in branches:
1762
+ out.append((path, fields.get("id") or os.path.basename(path).split("-")[0],
1763
+ branch))
1764
+ return out
1765
+
1766
+
1767
+ def cmd_sweep(args):
1768
+ """One idempotent pass over what a session left behind: every pending slice
1769
+ as one line naming its `slice resume` command, the landed worktrees and
1770
+ branches `reconcile --prune` removes, and a `phase:` whose branch is gone. It
1771
+ prints one line per change and closes with the count, so a second run over the
1772
+ same tree reports `0 change(s)`. The pending slices are printed, never
1773
+ touched: resuming one is the session's move, and closing it here would be the
1774
+ forgetting this exists to stop."""
1775
+ if args:
1776
+ return misuse("sweep takes nothing")
1777
+ # the shared checkout: the worktrees and branches this prunes live in the
1778
+ # repository, not in whichever worktree the command ran from
1779
+ root = main_root(repo_root(os.getcwd()))
1780
+ rows = tt.pending(root)
1781
+ for rec in rows:
1782
+ # one line each: the full brief is `slice resume <id>`'s, and a session
1783
+ # coming back to ten dead slices wants the list, not ten briefs
1784
+ print(tt.slice_line(rec))
1785
+ if not rows:
1786
+ print("no open or stopped slice")
1787
+ changed = 0
1788
+ bases = landing_bases(root)
1789
+ for line in prune_worktrees(root, keep=repo_root(os.getcwd())) + prune_branches(root, bases):
1790
+ print(line)
1791
+ # a "kept" line is a report, not a change: a tree holding one long-lived
1792
+ # worktree still has to reach `0 change(s)` on the second run. A slice a
1793
+ # removed worktree closed is one too (review of plan 108).
1794
+ if line.startswith(("worktree removed", "branch deleted", "slice ")):
1795
+ changed += 1
1796
+ for path, ident, branch in orphan_phases(root):
1797
+ # the same empty-value shape `stop` writes, so one parser reads both, and
1798
+ # the same wrapper the other record writers use: a plan that cannot be
1799
+ # written is a problem with the plan, not a traceback (review of plan 108)
1800
+ bad = write_fields(path, phase="", updated=today())
1801
+ if bad:
1802
+ for problem in bad:
1803
+ print("FAIL %s" % problem)
1804
+ continue
1805
+ print("phase cleared: %s (%s no longer exists, so no worktree can move it)"
1806
+ % (ident, branch))
1807
+ changed += 1
1808
+ print("swept: %d change(s)" % changed)
1809
+ return 0
1810
+
1811
+
1604
1812
  def main(argv):
1605
1813
  if len(argv) < 2:
1606
1814
  print(__doc__, file=sys.stderr)
@@ -1626,10 +1834,14 @@ def main(argv):
1626
1834
  return cmd_note(rest)
1627
1835
  if cmd == "spike":
1628
1836
  return cmd_spike(rest)
1837
+ if cmd == "unmet":
1838
+ return cmd_unmet(rest)
1629
1839
  if cmd == "slice":
1630
1840
  return cmd_slice(rest)
1841
+ if cmd == "sweep":
1842
+ return cmd_sweep(rest)
1631
1843
  return misuse("unknown command %r (start, phase, allow, stop, status, review, "
1632
- "close, tick, note, spike, slice)" % cmd)
1844
+ "close, tick, note, spike, unmet, slice, sweep)" % cmd)
1633
1845
 
1634
1846
 
1635
1847
  class ReaderMayLeave:
package/bin/tezgah-taste CHANGED
@@ -305,7 +305,9 @@ def mine_session(events):
305
305
 
306
306
 
307
307
  def cmd_mine(args):
308
- here = (tezgah_paths.linked_main(os.getcwd()) or repo_root(os.getcwd())
308
+ # sessions run in checkouts, never in a bare repository's git directory
309
+ main = tezgah_paths.linked_main(os.getcwd())
310
+ here = ((main and tezgah_paths.work_tree(main)) or repo_root(os.getcwd())
309
311
  or os.getcwd())
310
312
  root = os.path.abspath(os.path.expanduser(
311
313
  args.root or ("~/Projects" if args.all_projects else here)))
@@ -187,6 +187,14 @@ the `tezgah-research import` that moves its lines (`hooks/tezgah_context.py::sib
187
187
  Session start in a linked worktree makes its `.tezgah` a link to the main one
188
188
  (`hooks/tezgah_paths.py::link_workspace`). A relative `.tezgah/...` write lands
189
189
  there, and `/.tezgah` goes to the repository's `info/exclude`.
190
+ A bare repository (`app.git`, or `app/.bare` with its worktrees beside it) has no
191
+ main checkout. Its workspace is `<bare git dir>/.tezgah`, a private repository in
192
+ the git directory itself, with no `.gitignore` written there
193
+ (`hooks/tezgah_paths.py::linked_main`, `hooks/tezgah_paths.py::ensure_workspace`).
194
+ Every worktree's `.tezgah` links to it. No `git worktree remove`, detach or
195
+ switch in any worktree takes it, and a plan worktree reads its plan there.
196
+ The git directory holds no project file. A checkpoint, a cited file or an arena
197
+ evaluator is read in the worktree the command runs in (`hooks/tezgah_paths.py::work_tree`).
190
198
  A second clone is another repository, with a workspace of its own.
191
199
  A path inside `.tezgah` belongs to the repository around it, never to `.tezgah`.
192
200
  The workspace keeps its own `.git`, so git's top-level answers `.tezgah` there.
@@ -45,7 +45,7 @@
45
45
  "long_sentences": 66,
46
46
  "passive": 31,
47
47
  "replace_words": 1,
48
- "semicolons": 44
48
+ "semicolons": 43
49
49
  },
50
50
  "docs/glossary.md": {
51
51
  "long_sentences": 51,
@@ -78,7 +78,7 @@ the kill switches are files, not variables, and live in
78
78
  | `TYPESAFE_API_KEY` | `hooks/tezgah_judge.py::key`, `hooks/tezgah_paths.py` | `~/.config/typesafe/key` | The judgement seam's first third-party credential, used as `fallback` in `config.json` allows ([judge](judge.md)); omp also reads it for its own judge. |
79
79
  | `OPENROUTER_API_KEY` | `hooks/tezgah_judge.py::openrouter_key`, `bin/consult`, `bin/codegen`, `hooks/tezgah_models.py` | `~/.config/openrouter/key` | The seam's chat fallback, consult's and codegen's `openrouter` provider, and omp's `any` family. |
80
80
  | `DEEPSEEK_API_KEY`, `INCEPTION_API_KEY` | `bin/consult`, `bin/codegen`, `hooks/tezgah_replay.py::label_model` | `~/.config/deepseek/key`, `~/.config/inception/key` | The `deepseek` and `inception` providers of consult and codegen; `DEEPSEEK_API_KEY` alone is also the replay sheet's `--provider deepseek` rater. |
81
- | `ANTHROPIC_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `ZAI_API_KEY` | `hooks/tezgah_models.py::OMP_AUTH` | omp's own auth store | Read as presence only: whether omp can run the `anthropic` or `zai` model family, so the model table can pick a row omp will run. |
81
+ | `ANTHROPIC_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `ZAI_API_KEY`, `OPENAI_CODEX_OAUTH_TOKEN` | `hooks/tezgah_models.py::OMP_AUTH` | omp's own auth store | Read as presence only: whether omp can run the `anthropic`, `zai` or `openai` model family, so the model table can pick a row omp will run. |
82
82
  | `TEZGAH_TYPESAFE_URL` | `hooks/tezgah_judge.py::endpoint` | `https://api.typesafe.ai/v1/systemone` | Repoints the seam's TypeSafe endpoint (tests). Plain `http` is refused unless the host is this machine; a cross-host redirect is refused. |
83
83
  | `TEZGAH_OPENROUTER_URL` | `hooks/tezgah_judge.py::openrouter_url` | OpenRouter's chat-completions URL | Repoints the seam's chat fallback (tests). Plain `http` is refused unless the host is this machine. |
84
84
  | `TEZGAH_DEEPSEEK_URL` | `hooks/tezgah_replay.py::_deepseek` | `https://api.deepseek.com/chat/completions` | Repoints the replay sheet's `--provider deepseek` rater (tests). Plain `http` is refused unless the host is this machine. |
package/docs/evidence.md CHANGED
@@ -126,7 +126,7 @@ a write tool is `edit`, a shell call is `verify` when its command matches the ch
126
126
  | `refusal` | `stop_reason`, for a blocked reply that claimed nothing - a work-only or a shape refusal - with the same `blocked: <class>` detail (row version 3) | `counters.refusals`, and `shape_blocked` for a shape class; never a claim; `NOT_TOOL_HOOK` lists it |
127
127
  | `after_block` | `hooks/tezgah_integrity.py::stop_reason`, on a reply after this rule's own block (a turn holding a `blocked:` claim or refusal row) that is not refused again (`_record_only`) on Claude, Codex, Cursor and omp; `detail` is `would block: <class>`, `ok` or `no claim` | nothing yet: a record for the after-block observation window, not a claim and not a second `shape` row, so `counters` counts it neither as a claim nor as a reply; `NOT_TOOL_HOOK` lists it |
128
128
  | `subagent_end` | `stop_reason(..., subagent=True)`, from Claude's SubagentStop (`hooks/hooks.json`) and Cursor's subagentStop `summary`; the evidence half only, never a block (ADR 011); `detail` as `after_block`'s | nothing yet: the rows plan 055 replays before a subagent end may block; `NOT_TOOL_HOOK` lists it. On Claude a subagent's tool rows sit in the parent's ledger, so until the agent key (plan 050) lands the row judges the parent's turn |
129
- | `stop_spec` | `hooks/tezgah_stopspec.py::shadow`, from `stop_reason`, one per reply that leaves a claim or a refusal row, keyed like the claim row. The `detail` is `agree <where>: <class>`, `disagree <where>: <imperative> -> <spec>` (`ok` for an allow) or `error: <type>`. `<where>` is `fold` when the selector reached the fold and `selector` when it decided alone | `tests/stop_spec_eval.py --shadow-rows`, for plan 063's live agreement bar, read on the `fold` rows. It never feeds the verdict. `NOT_TOOL_HOOK` lists it |
129
+ | `stop_spec` | nothing since plan 101. Plan 063's shadow wrote one per reply that left a claim or a refusal row: `agree`, `disagree` or `error`, and `fold` or `selector`. The fold it compared against is gone | nothing. `NOT_TOOL_HOOK` still lists it and the replay keeps it beside its claim (`hooks/tezgah_replay.py::SIDE_KINDS`), so an old ledger reads as it did |
130
130
  | `deny`, `nudge` | the [gate](gate.md)'s `hooks/tezgah_gate.py::_deny`, first-nudge `hooks/tezgah_gate.py::decision` | `hooks/tezgah_integrity.py::counters` |
131
131
  | `snapshot`, `rollback` | `hooks/tezgah_snapshot.py::_capture_one`, `hooks/tezgah_snapshot.py::restore` | `hooks/tezgah_integrity.py::_snapshot_hash`; no counter |
132
132
  | `compact` | `hooks/tezgah_integrity.py::note_compaction`, from the post-compaction path (`tezgah_context.remember_compaction` `hooks/tezgah_context.py::remember_compaction`) | `hooks/tezgah_integrity.py::_counts` (what `counters` folds with) |
@@ -321,7 +321,7 @@ The first four judge how the reply is written (`_shape_block`, `hooks/tezgah_int
321
321
 
322
322
  The four shape classes share their switches with the text they enforce: `adhd-off` or a repo's `.no-adhd` lifts 1–3 (`_adhd_armed`, `hooks/tezgah_integrity.py::_adhd_armed`), `exec-mode.off` lifts 4, and a session whose environment carries `TEZGAH_NESTED` (an agent CLI consult started, whose English answer is read by code) is not judged for shape at all. A subagent's reply is never refused: omp's `session_stop` does not fire for task sessions, and a Claude SubagentStop or Cursor subagentStop leaves a record-only `subagent_end` row judged on the evidence half alone (ADR 011). The thresholds were set on the owner's omp transcripts (2026-09-27: 1,495 assistant replies with at least 25 prose words): English prose scored 0.000–0.026, the most English-heavy Turkish reply 0.077 and the bulk 0.3–0.7, and 24 of the 1,495 fall under the cut, all English or Chinese prose. The trade-off is on the Turkish side: a Turkish reply that is mostly quoted English outside backticks can fall under 6%, and the block text tells the model to put the English in backticks or a fence. `tests/test_integrity.py` `ReplyShapeCorpus` pins which realistic replies pass and which block. A lead that announces what follows (`preamble-open`) stays report-only: "Sonuç:" over a list is an answer label and reads like a preamble to any regex.
323
323
 
324
- 5. **check failed** — the newest check in the session failed (`hooks/tezgah_integrity.py::_evidence_block`).
324
+ 5. **check failed** — the newest check in the session failed (`hooks/tezgah_stopspec.py::verdict`).
325
325
  6. **partial failure** — this turn recorded a `verify_fail` and nothing passed since (`hooks/tezgah_integrity.py::_partial_state`).
326
326
  7. **no ui_ok** — this turn changed a UI source (`hooks/tezgah_integrity.py::UI_PATH`) and the
327
327
  check that passed was not one that sees the screen: a unit run never does. A browser/e2e/visual
@@ -330,7 +330,7 @@ The four shape classes share their switches with the text they enforce: `adhd-of
330
330
  CLI capture) is the evidence this class asks for, and it has to be newer than
331
331
  the UI write it is about - not than the newest write of anything, so an unrelated file written
332
332
  after the screen read does not stale that read (`hooks/tezgah_integrity.py::_ui_evidence`,
333
- branch `hooks/tezgah_integrity.py::_evidence_block`). The write kinds are the freshness fold's own (`_change_row`: a write tool's
333
+ branch `hooks/tezgah_stopspec.py::verdict`). The write kinds are the freshness fold's own (`_change_row`: a write tool's
334
334
  `edit` row or a shell call's `run` row the gate saw change the tree), so a UI source written
335
335
  through a redirect owes the same proof. The MCP half of that proof is read off the row's own
336
336
  tool name (`hooks/tezgah_integrity.py::_screen_read`), which is the only field that
@@ -342,7 +342,7 @@ The four shape classes share their switches with the text they enforce: `adhd-of
342
342
  under a component/views/widgets
343
343
  directory, or a name that carries the convention on its own), a fresh read of the screen is not
344
344
  enough by itself and a `tezgah-design check` row has to be newer than the component write it
345
- judges (`hooks/tezgah_integrity.py::_design_evidence`, branch `hooks/tezgah_integrity.py::_evidence_block`) - and it has
345
+ judges (`hooks/tezgah_integrity.py::_design_evidence`, branch `hooks/tezgah_stopspec.py::verdict`) - and it has
346
346
  to be a check whose pass was seen (`passing_check`, `hooks/tezgah_integrity.py::passing_check`), because
347
347
  a screenshot says what a component looks like and the contract is the only thing that says whether
348
348
  it is on the repository's floor. The refusal names the command to run. Both of the checker's verbs
@@ -351,7 +351,7 @@ The four shape classes share their switches with the text they enforce: `adhd-of
351
351
  it. A screen is unchanged: the page a person looks at owes the read, not the contract.
352
352
  8. **stale evidence** — the newest check that passed ran before the newest write the gate saw change
353
353
  the tree, so it verified an earlier revision of it (`_last_pass`/`_last_change`/`_stale_paths`
354
- `hooks/tezgah_integrity.py::_last_pass`, `hooks/tezgah_integrity.py::_last_change`, `hooks/tezgah_integrity.py::_stale_paths`, branch `hooks/tezgah_integrity.py::_evidence_block`). A write counts as a change whether the gate saw it as a
354
+ `hooks/tezgah_integrity.py::_last_pass`, `hooks/tezgah_integrity.py::_last_change`, `hooks/tezgah_integrity.py::_stale_paths`, branch `hooks/tezgah_stopspec.py::verdict`). A write counts as a change whether the gate saw it as a
355
355
  write tool or as a shell command that redirected into the file - `_change_row` (`hooks/tezgah_integrity.py::_change_row`) reads
356
356
  an `edit` row, a `run` row whose captured target moved, or a formatter's write mode - while a `verify*` row never does, so
357
357
  a check redirecting its own log cannot stale itself. A write *outside* the workspace is not one
@@ -365,10 +365,14 @@ The four shape classes share their switches with the text they enforce: `adhd-of
365
365
  change here. A row with no `repo` binds to nothing. That covers an old row and a host that sent
366
366
  no cwd. A change with no `target`, such as a shell write, binds to nothing too.
367
367
  9. **no verify_ok** — this turn recorded a step (`edit`, `verify`, `verify_fail`, `run`,
368
- `interrupted`) and no check passed in the session (`hooks/tezgah_integrity.py::_evidence_block`). A turn with no step at all
368
+ `interrupted`) and no check passed in the session (`hooks/tezgah_stopspec.py::verdict`). A turn with no step at all
369
369
  has only its words as the trigger. There a claim word inside a question or under a negation in
370
370
  its own clause is no claim (`asserted_claims`). Examples: "testler geçti mi?", "is it done?",
371
371
  "not tested yet", "tamamlandı değil". "Tamamlandı, push edeyim mi?" still claims.
372
+ A done or tested claim over a check nobody saw pass owes this class too, as a lost call does.
373
+ That is a `verify_ok` row that is no `passing_check`, such as exit 0 with nothing returned. A
374
+ work-free turn of one such row used to end on "Tamamlandı, tüm testler geçti" (plan 101,
375
+ `hooks/tezgah_integrity.py::_stop_block`). A reply with no claim word still ends there.
372
376
  A prose edit is no step and no change (`hooks/tezgah_integrity.py::_prose_edit`,
373
377
  `hooks/tezgah_integrity.py::_work_row`). Such an `edit` row has a real `target` ending in
374
378
  `.md`, `.markdown`, `.rst` or `.adoc`. It owes no check and does not stale a pass. An agent
@@ -379,7 +383,7 @@ The four shape classes share their switches with the text they enforce: `adhd-of
379
383
  10. **no external read** — the reply states the state of a system tezgah does not own — a registry,
380
384
  a release, a tag, a formula, a CI run — and no read of that system ran in the same turn
381
385
  (`hooks/tezgah_integrity.py::_external_claim`, over `EXTERNAL_SYSTEM`/`EXTERNAL_STATE`
382
- `hooks/tezgah_integrity.py::EXTERNAL_SYSTEM`, `hooks/tezgah_integrity.py::EXTERNAL_STATE` and the CI pair `EXTERNAL_CI`/`EXTERNAL_CI_STATE` `hooks/tezgah_integrity.py::EXTERNAL_CI`, `hooks/tezgah_integrity.py::EXTERNAL_CI_STATE`; branch `hooks/tezgah_integrity.py::_evidence_block`).
386
+ `hooks/tezgah_integrity.py::EXTERNAL_SYSTEM`, `hooks/tezgah_integrity.py::EXTERNAL_STATE` and the CI pair `EXTERNAL_CI`/`EXTERNAL_CI_STATE` `hooks/tezgah_integrity.py::EXTERNAL_CI`, `hooks/tezgah_integrity.py::EXTERNAL_CI_STATE`; branch `hooks/tezgah_stopspec.py::verdict`).
383
387
  The two halves have to sit within `EXTERNAL_GAP` = 45 characters of each other on one line
384
388
  (`hooks/tezgah_integrity.py::_external_pair`), and both are read on the reply's prose with inline code, paths,
385
389
  URLs and identifiers blanked: `brew tap` inside a code span, the `.github/workflows/...` in a
@@ -525,27 +529,26 @@ The four shape classes share their switches with the text they enforce: `adhd-of
525
529
  transcripts. It refuses once per turn and counts like `residual work`
526
530
  (`hooks/tezgah_integrity.py::LIST_BLOCKS`).
527
531
 
528
- Classes 5–10 also exist as a past-time temporal spec (`hooks/tezgah_stopspec.py::FORMULAS`,
532
+ A past-time temporal spec decides classes 5–10 (`hooks/tezgah_stopspec.py::FORMULAS`,
529
533
  plan 063). Seven formulas read ten row atoms (`ROW_ATOMS`) and the turn marker. The module compiles
530
534
  each formula to a one-pass monitor over the rows (`hooks/tezgah_stopspec.py::compile_table`), and the
531
- order above is its decision list (`ORDER`). The spec runs in shadow. `stop_reason` asks it after the imperative
532
- fold, inside its own `try`, when the reply leaves a claim or a refusal row. It writes one `stop_spec` row
533
- per reply. The row says `agree`, `disagree` or `error`. It also says `fold` when the selector reached
534
- the fold and `selector` when it decided alone. The verdict stays the imperative one. Both folds share one selector,
535
- `_stop_block`, through its `fold` argument. It picks the rows, runs the lost-began path and
536
- refuses "evidence tampered". The admission below and class 11 belong to the selector too. The
537
- spec's verdict passes through them. `ESCAPES` names the three predicates that read beyond one row: the
535
+ order above is its decision list (`ORDER`, walked by `hooks/tezgah_stopspec.py::verdict`). The spec
536
+ ran in shadow beside the imperative fold until it agreed on every Stop that reached the fold.
537
+ Plan 101 then made it the fold and deleted the imperative one. `_stop_block` calls it as its
538
+ evidence fold (`hooks/tezgah_stopspec.py::fold`), and `hooks/tezgah_integrity.py::_evidence_text`
539
+ words the class it picks with the refusal text. `_stop_block` stays the selector. It picks the
540
+ rows, runs the lost-began path and refuses "evidence tampered". The admission below and class 11
541
+ belong to the selector too. `ESCAPES` names the three predicates that read beyond one row: the
538
542
  began-row join, the repository binding and the lost-began set. Plan 063 fixed that list before
539
- the first shadow row. `TEZGAH_STOPSPEC_STRICT=1` makes a disagreement raise, for the tests.
540
- `tests/stop_spec_eval.py` measures the bet: agreement on plan 055's replayed Stop events and on
541
- the live rows, the added latency, and generated mutants of the table.
543
+ the first shadow row. `tests/stop_spec_eval.py --mutants` mutates the table and runs the in-process
544
+ Stop tests against each mutant.
542
545
 
543
546
  The trigger for 5–9 is the turn's own evidence, not its words: a turn that did work and never saw a
544
547
  check pass is refused whatever the reply says (`hooks/tezgah_integrity.py::_stop_block`). Two cases are judged against the
545
548
  whole session instead of the turn:
546
549
 
547
550
  - **A claim in a turn with no work.** A done/tested claim made in a turn that recorded no work row
548
- is judged by the same fold as a turn (`_evidence_block`), run over the session rows before
551
+ is judged by the same fold as a turn (the spec's), run over the session rows before
549
552
  this turn (`_before_turn`): every class the turn fold has - `check failed`, `no verify_ok`,
550
553
  `stale evidence`, a pending UI or design check, a partial failure - applies to the session too.
551
554
  A session that did no work at all still ends, and a turn that ran its own passing check is
@@ -575,7 +578,7 @@ whole session instead of the turn:
575
578
 
576
579
  What lets the turn end is the absence
577
580
  of both — no work row and no claim vocabulary (`hooks/tezgah_integrity.py::_stop_block`), the one shape class 10 is exempt from
578
- — or a `passing_check` row newer than the newest write seen to change the tree (`hooks/tezgah_integrity.py::_evidence_block`), where
581
+ — or a `passing_check` row newer than the newest write seen to change the tree (`hooks/tezgah_stopspec.py::verdict`), where
579
582
  a fresh screen proof stands in the same
580
583
  place as a unit pass; when that write was a UI source, the passing row has to be a UI proof — a
581
584
  check that renders, or a read of the screen — and when it was a component, the design check has to be