@r1z4x/tezgah 1.7.0 → 1.8.2

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.
@@ -52,6 +52,7 @@ out to git and read omp transcripts; the Stop rule reads `pending` (fail open).
52
52
  import contextlib
53
53
  import os
54
54
  import re
55
+ import shlex
55
56
 
56
57
  TASK_PHASES = ("discovery", "implementation", "verification")
57
58
  WRITE_PHASES = ("implementation", "verification")
@@ -61,12 +62,15 @@ def repo_root(cwd, base):
61
62
  """The git top-level at or above cwd and under base, else the first path
62
63
  component below base. Walks up for a `.git` entry (dir or file: a worktree's
63
64
  is a file); no subprocess. Same answer as tezgah_context.repo_root without
64
- importing the context module into the gate's hot path."""
65
+ importing the context module into the gate's hot path; inside `.tezgah` the
66
+ project around it (`tezgah_paths.project_top`)."""
67
+ from tezgah_paths import project_top
65
68
  cwd = os.path.realpath(cwd)
66
69
  inside = os.path.realpath(base) if base else None
67
70
  cur = cwd
68
71
  while True:
69
72
  if os.path.exists(os.path.join(cur, ".git")):
73
+ cur = project_top(cur)
70
74
  if inside and _under(cur, inside):
71
75
  return cur
72
76
  break
@@ -164,6 +168,10 @@ _MARKER = re.compile(r"unverifiable(?![-\w])", re.I)
164
168
  # names the word ("either the command or the word `unverifiable` followed by
165
169
  # why") has no separator there and is not a declaration.
166
170
  _SEPARATORS = ":,;(-"
171
+ # An unticked item the owner accepted as not met: `UNMET:` and the reason, upper
172
+ # case so prose that names the word is not a marker. `close NNN done` lets
173
+ # such an item through and refuses every other open box (open_items).
174
+ _UNMET = re.compile(r"(?<!\w)UNMET:\s*(\S.*)")
167
175
  _SPAN = re.compile(r"`([^`\n]+)`")
168
176
  # A runner whose test entry point is a bare subcommand: `pnpm test`, `cargo
169
177
  # test`, `uv run pytest` carry no option and no path, yet each is the command.
@@ -276,6 +284,14 @@ def acceptance_gap(text):
276
284
  return unproven(acceptance_items(text))
277
285
 
278
286
 
287
+ def open_items(text):
288
+ """The plan's unticked Acceptance items that carry no `UNMET: <reason>`, as
289
+ (number counted from 1, item) pairs: what `close NNN done` refuses on. Plan
290
+ 095 closed done with an open box because close read only the review."""
291
+ return [(n, item) for n, item in enumerate(acceptance_items(text), 1)
292
+ if not item["checked"] and not _UNMET.search(item["text"])]
293
+
294
+
279
295
  # The `checkpoint:` values a plan can carry: a commit sha (the tree was clean at
280
296
  # the phase move, so the boundary is already real), or `pending <sha>` while the
281
297
  # tree still holds work no commit names - `<sha>` being the `HEAD` the commit the
@@ -314,13 +330,15 @@ def slug(path):
314
330
  return name[:-3] if name.endswith(".md") else name
315
331
 
316
332
 
317
- def checkpoint_command(path):
333
+ def checkpoint_command(path, worktree=None):
318
334
  """The one command that makes a plan's boundary real: a commit whose message
319
335
  marks where the risky work starts. Named by the CLI's note and by the gate's
320
336
  refusal - the same string, from the same caller-visible place - and running it
321
337
  is also what clears the refusal: it moves HEAD off the sha the record wrote
322
- (`checkpoint_sha`)."""
323
- return 'git add -A && git commit -m "checkpoint: before %s"' % slug(path)
338
+ (`checkpoint_sha`). A plan held by its own worktree (`branch_task`) commits
339
+ there, so the command names that checkout."""
340
+ git = "git -C %s" % shlex.quote(worktree) if worktree else "git"
341
+ return '%s add -A && %s commit -m "checkpoint: before %s"' % (git, git, slug(path))
324
342
 
325
343
 
326
344
  def spike_unanswered(text):
@@ -452,20 +470,53 @@ def parallel(rows):
452
470
  return picked if len(picked) > 1 else []
453
471
 
454
472
 
455
- def active(cwd, base):
456
- """The active task as a dict, or None. Scans `<repo_root>/.tezgah/plans/open/*.md`
457
- sorted by name, first file whose frontmatter carries a valid `phase`.
458
- Dict: {"id","title","phase","allowed_paths","checkpoint","path"}: id/title are
459
- the frontmatter values (id falls back to the filename's NNN), checkpoint is
460
- the frontmatter value or None when the plan carries none, path is the file.
473
+ def active(cwd, base, held=False):
474
+ """The active task as a dict, or None. A linked worktree on a `plan/NNN-...`
475
+ branch answers plan NNN when that plan carries a phase (`branch_task`), so
476
+ two plans can run at once, one per worktree. Anywhere else - the main
477
+ checkout, or a worktree on another branch - it reads the main checkout's
478
+ `.tezgah/plans/open/*.md` sorted by name: the first plan carrying a valid
479
+ `phase` that no linked worktree holds (`plan_holders`). When every phased
480
+ plan is held there is none: a held plan's scope is its worktree's, and a
481
+ checkout outside it reading that scope held its writes to the wrong plan
482
+ (plan 097, D6). `held` answers the first phased plan then instead, marked
483
+ `"held": True`: the plan bin/tezgah-task's `phase`, `allow`, `stop` and
484
+ `status` act on from the main checkout, and the record the gate still
485
+ refuses a direct write to - while it holds no write to that plan's scope
486
+ outside its worktree. The context line never passes it.
487
+ Dict: {"id","title","phase","allowed_paths","checkpoint","branch","path"}:
488
+ id/title are the frontmatter values (id falls back to the filename's NNN),
489
+ checkpoint is the frontmatter value or None when the plan carries none,
490
+ path is the file.
461
491
  Never raises: an unreadable directory or file means None (readers fail
462
492
  open)."""
463
493
  root = repo_root(cwd, base)
494
+ own = branch_task(root)
495
+ if own:
496
+ return own
497
+ from tezgah_paths import linked_main
498
+ main = linked_main(root) or root
499
+ records = _phased(main)
500
+ branches = _worktree_branches(main) if records else {}
501
+ for record in records:
502
+ if not any(_binds(record["id"], b) for b in branches):
503
+ return record
504
+ if not held or not records:
505
+ return None
506
+ records[0]["held"] = True
507
+ return records[0]
508
+
509
+
510
+ def _phased(root, ident=None):
511
+ """Every open plan under `root` carrying a valid phase - plan `ident` only,
512
+ when one is named - as active()'s dicts, in name order; [] when there is
513
+ none or a plan file cannot be read."""
464
514
  directory = os.path.join(root, ".tezgah", "plans", "open")
465
515
  try:
466
516
  names = sorted(os.listdir(directory))
467
517
  except OSError:
468
- return None
518
+ return []
519
+ out = []
469
520
  for name in names:
470
521
  if not name.endswith(".md"):
471
522
  continue
@@ -474,29 +525,158 @@ def active(cwd, base):
474
525
  with open(path, "r", encoding="utf-8") as fh:
475
526
  text = fh.read()
476
527
  except OSError:
477
- return None
528
+ return []
478
529
  fields = frontmatter(text)
479
530
  if fields.get("phase") not in TASK_PHASES:
480
531
  continue
481
- # A plan the repository shipped is data, not the user's task: its phase
482
- # and allowlist would lock writes in a cloned repo (audit L-16). Only
483
- # positive evidence turns the rule off - the index tracks `.tezgah` -
484
- # never a can't-tell, which would switch the user's own guard off in
485
- # silence (review R2); the answer is cached on the index's stat.
486
- from tezgah_paths import workspace_tracked
487
- if workspace_tracked(root):
488
- return None
489
- return {"id": fields.get("id") or name.split("-")[0],
490
- "title": fields.get("title", ""),
491
- "phase": fields["phase"],
492
- "allowed_paths": allowed_paths(text),
493
- # read from the frontmatter already in hand, so the gate's
494
- # checkpoint rule costs this module no second read of the plan
495
- "checkpoint": fields.get("checkpoint"),
496
- "path": path}
532
+ ident_here = fields.get("id") or name.split("-")[0]
533
+ if ident is not None and ident_here != ident:
534
+ continue
535
+ out.append({"id": ident_here,
536
+ "title": fields.get("title", ""),
537
+ "phase": fields["phase"],
538
+ "allowed_paths": allowed_paths(text),
539
+ # read from the frontmatter already in hand, so the gate's
540
+ # checkpoint rule costs this module no second read of the plan
541
+ "checkpoint": fields.get("checkpoint"),
542
+ "branch": fields.get("branch", ""),
543
+ "path": path})
544
+ # A plan the repository shipped is data, not the user's task: its phase
545
+ # and allowlist would lock writes in a cloned repo (audit L-16). Only
546
+ # positive evidence turns the rule off - the index tracks `.tezgah` -
547
+ # never a can't-tell, which would switch the user's own guard off in
548
+ # silence (review R2); the answer is cached on the index's stat.
549
+ from tezgah_paths import workspace_tracked
550
+ if out and workspace_tracked(root):
551
+ return []
552
+ return out
553
+
554
+
555
+ _PLAN_BRANCH = re.compile(r"plan/(\d+)(?:-|$)")
556
+
557
+
558
+ def worktree_branch(top):
559
+ """The branch the linked worktree `top` checks out, read from its admin
560
+ `HEAD` without a fork (the gate asks on every write); None for a detached
561
+ HEAD, a plain checkout or anything unreadable."""
562
+ try:
563
+ with open(os.path.join(top, ".git"), encoding="utf-8") as fh:
564
+ admin = fh.readline().strip()[len("gitdir:"):].strip()
565
+ with open(os.path.join(top, admin, "HEAD"), encoding="utf-8") as fh:
566
+ head = fh.readline().strip()
567
+ except (OSError, UnicodeDecodeError):
568
+ return None
569
+ prefix = "ref: refs/heads/"
570
+ return head[len(prefix):] if head.startswith(prefix) else None
571
+
572
+
573
+ def plan_of_branch(branch):
574
+ """The plan id a `plan/NNN-slug` branch names, else None."""
575
+ found = _PLAN_BRANCH.match(branch or "")
576
+ return found.group(1) if found else None
577
+
578
+
579
+ def _binds(ident, branch):
580
+ """Does `branch` hold plan `ident`? It has to name the plan: `plan/NNN-...`.
581
+ A plan's declared `branch:` is one of those, and the slices it fans out to
582
+ run on others (`plan/097-records` beside `plan/097-workspace-integrity`):
583
+ each slice is held to its plan's scope, never to another plan's, which is
584
+ what a first-unheld-plan reading gave it (plan 097, D6)."""
585
+ return bool(branch) and plan_of_branch(branch) == ident
586
+
587
+
588
+ def _worktree_branches(main):
589
+ """{branch: top} for every linked worktree of the main checkout `main`."""
590
+ from tezgah_paths import worktrees
591
+ out = {}
592
+ for top in worktrees(main)[1:]:
593
+ branch = worktree_branch(top)
594
+ if branch:
595
+ out[branch] = top
596
+ return out
597
+
598
+
599
+ def plan_holders(main):
600
+ """{plan id: worktree top} for every open plan under the main checkout
601
+ `main` that a linked worktree on one of its branches (`_binds`) holds, phase
602
+ or not. Of several, the worktree on the plan's declared `branch:` holds it -
603
+ the checkout its checkpoint, review and diff are read in - else the first
604
+ by branch name."""
605
+ branches = _worktree_branches(main)
606
+ out = {}
607
+ if not branches:
608
+ return out
609
+ for name, _, text in _plan_files(main, "open"):
610
+ fields = frontmatter(text)
611
+ ident = _plan_id(fields, name)
612
+ held = sorted(b for b in branches if _binds(ident, b))
613
+ if held:
614
+ declared = fields.get("branch", "")
615
+ out[ident] = branches[declared if declared in held else held[0]]
616
+ return out
617
+
618
+
619
+ def branch_task(top):
620
+ """Plan NNN's record (active()'s dict, plus "worktree": `top`, the checkout
621
+ whose HEAD and tree its checkpoint is read against) when `top` is a linked
622
+ worktree on a `plan/NNN-...` branch (`_binds`) and plan NNN, open under the
623
+ main checkout, carries a phase. None otherwise - another branch, a plan with
624
+ no phase, an unknown mapping - and the caller reads the main checkout's task
625
+ (plan 093)."""
626
+ ident = plan_of_branch(worktree_branch(top))
627
+ if ident is None:
628
+ return None
629
+ from tezgah_paths import linked_main
630
+ main = linked_main(top)
631
+ for record in _phased(main, ident) if main else ():
632
+ record["worktree"] = top
633
+ return record
497
634
  return None
498
635
 
499
636
 
637
+ def worktree_task(path, cwd, base):
638
+ """The record a write to `path` is held to when it lands in a sibling linked
639
+ worktree on a plan branch (`branch_task`), else None. A path under cwd's own
640
+ root is active()'s to answer, so it pays one realpath and nothing more."""
641
+ root = repo_root(cwd, base)
642
+ path = os.path.expanduser(path)
643
+ real = os.path.realpath(path if os.path.isabs(path) else os.path.join(cwd, path))
644
+ if _under(real, root):
645
+ return None
646
+ top = _sibling_worktree(real, root)
647
+ return branch_task(top) if top else None
648
+
649
+
650
+ def main_relative(path, cwd, base):
651
+ """`path` (absolute or relative to cwd) as a posix path relative to the main
652
+ checkout of cwd's repository - the checkout that keeps `.tezgah/plans` -
653
+ or None outside it. A session in a linked worktree reads the same records
654
+ as one in the main checkout."""
655
+ from tezgah_paths import linked_main
656
+ root = repo_root(cwd, base)
657
+ main = linked_main(root) or root
658
+ path = os.path.expanduser(path)
659
+ real = os.path.realpath(path if os.path.isabs(path) else os.path.join(cwd, path))
660
+ if real == main or not _under(real, main):
661
+ return None
662
+ return real[len(main) + 1:].replace(os.sep, "/")
663
+
664
+
665
+ def phased_record(root, rel):
666
+ """True when `rel` (relative to the main checkout `root`) is an open plan
667
+ carrying a valid phase - a record only bin/tezgah-task writes, whichever
668
+ plan the main checkout reads as active. Matched without case, since a
669
+ case-insensitive filesystem opens `.TEZGAH/PLANS/open/x.md` as the record."""
670
+ parts = (rel or "").split("/")
671
+ if (len(parts) != 4 or "/".join(parts[:3]).casefold() != ".tezgah/plans/open"
672
+ or not parts[3].casefold().endswith(".md")):
673
+ return False
674
+ for name, _, text in _plan_files(root, "open"):
675
+ if name.casefold() == parts[3].casefold():
676
+ return frontmatter(text).get("phase") in TASK_PHASES
677
+ return False
678
+
679
+
500
680
  def match(rel, pattern):
501
681
  """Glob match for one allowlist pattern against a repo-relative posix path.
502
682
  `**` crosses separators, `*` does not, everything else is literal."""
@@ -574,15 +754,67 @@ def _sibling_worktree(real, root):
574
754
  return top if os.path.realpath(back) == os.path.join(top, ".git") else None
575
755
 
576
756
 
757
+ @contextlib.contextmanager
758
+ def plans_lock(path):
759
+ """Hold the exclusive lock every plan record writer takes, on the `plans/`
760
+ directory above the plan file `path`, across its whole read-modify-write:
761
+ two writers in two checkouts lost one write in 40 rounds without it (plan
762
+ 097, E7). The directory itself is locked, so no lock file is left for the
763
+ private repository's `add -A` to commit (review of plan 097, F6). Raises
764
+ OSError when the directory cannot be opened."""
765
+ try:
766
+ import fcntl
767
+ except ImportError:
768
+ # ponytail: Windows has no fcntl, so writers run unlocked there; the
769
+ # rename in _write still keeps every read whole, but two concurrent
770
+ # writers can lose one write. msvcrt.locking is the fuller version.
771
+ fcntl = None
772
+ if fcntl is None:
773
+ yield
774
+ return
775
+ fd = os.open(os.path.dirname(os.path.dirname(os.path.realpath(path))), os.O_RDONLY)
776
+ try:
777
+ fcntl.flock(fd, fcntl.LOCK_EX)
778
+ yield
779
+ finally:
780
+ os.close(fd) # closing the descriptor releases the lock
781
+
782
+
783
+ def _write(path, text):
784
+ """Replace the file at `path` with `text` whole: written to a temp file
785
+ beside it, then renamed over it, so a reader sees the old record or the new
786
+ one and never a truncated one - which read as no frontmatter, so no active
787
+ task, and opened the gate (29 torn reads in 40, plan 097 E7). Keeps the
788
+ file's mode. Raises OSError."""
789
+ import tempfile
790
+ path = os.path.realpath(path)
791
+ folder, name = os.path.split(path)
792
+ fd, tmp = tempfile.mkstemp(prefix="." + name + ".", suffix=".tmp", dir=folder)
793
+ try:
794
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
795
+ fh.write(text)
796
+ os.chmod(tmp, os.stat(path).st_mode & 0o7777)
797
+ os.replace(tmp, path)
798
+ except BaseException:
799
+ with contextlib.suppress(OSError):
800
+ os.unlink(tmp)
801
+ raise
802
+
803
+
577
804
  def set_fields(path, **fields):
578
- """Rewrite these frontmatter keys in the plan file in place: replace the
805
+ """Rewrite these frontmatter keys in the plan file: replace the
579
806
  `key: value` line when present, insert it before the closing `---` when not.
580
807
  `allowed_paths` takes a list and is written as a `- item` block, replacing an
581
808
  existing block. `updated` is not special-cased here: the CLI passes today's
582
809
  date under it, as it does for a research line's `created:`, so this module
583
- needs no clock of its own. Raises OSError."""
584
- with open(path, "r", encoding="utf-8") as fh:
585
- lines = fh.read().split("\n")
810
+ needs no clock of its own. Under the plans lock, written whole (`_write`).
811
+ Raises OSError."""
812
+ with plans_lock(path):
813
+ _set_fields(path, fields)
814
+
815
+
816
+ def _set_fields(path, fields):
817
+ lines = _lines(path)
586
818
  if not lines or lines[0].strip() != "---":
587
819
  lines = ["---", "---"] + lines
588
820
  close = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
@@ -603,8 +835,17 @@ def set_fields(path, **fields):
603
835
  end += 1
604
836
  block[head:end] = fresh
605
837
  lines[1:close] = block
606
- with open(path, "w", encoding="utf-8") as fh:
607
- fh.write("\n".join(lines))
838
+ _write(path, "\n".join(lines))
839
+
840
+
841
+ def close_record(path, target, **fields):
842
+ """Write `fields` into the plan at `path` and move it to `target`, under
843
+ the plans lock: a tick racing the close then fails on the moved file instead
844
+ of writing it back into `open/`. Raises OSError."""
845
+ with plans_lock(path):
846
+ _set_fields(path, fields)
847
+ os.makedirs(os.path.dirname(target), exist_ok=True)
848
+ os.replace(path, target)
608
849
 
609
850
 
610
851
  def _field_lines(key, value):
@@ -624,36 +865,41 @@ def _lines(path):
624
865
  return fh.read().split("\n")
625
866
 
626
867
 
627
- def _write_lines(path, lines):
628
- with open(path, "w", encoding="utf-8") as fh:
629
- fh.write("\n".join(lines))
630
-
631
-
632
- def set_box(path, line, checked):
633
- """Set the `- [ ]`/`- [x]` box on 1-based `line` (an acceptance item's
634
- `line`). Raises OSError."""
635
- lines = _lines(path)
636
- lines[line - 1] = re.sub(r"- \[[ xX]\]", "- [x]" if checked else "- [ ]",
637
- lines[line - 1], count=1)
638
- _write_lines(path, lines)
868
+ def set_box(path, n, checked):
869
+ """Set the `- [ ]`/`- [x]` box of Acceptance item `n` (1-based, as `tick`
870
+ counts) and return the item, or None when the plan has no item `n`. The
871
+ item's line is found under the plans lock: read before it, a frontmatter
872
+ line another writer inserted moved the box ticked (review of plan 097,
873
+ F5). Raises OSError."""
874
+ with plans_lock(path):
875
+ lines = _lines(path)
876
+ items = acceptance_items("\n".join(lines))
877
+ if not 1 <= n <= len(items):
878
+ return None
879
+ line = items[n - 1]["line"] - 1
880
+ lines[line] = re.sub(r"- \[[ xX]\]", "- [x]" if checked else "- [ ]",
881
+ lines[line], count=1)
882
+ _write(path, "\n".join(lines))
883
+ return items[n - 1]
639
884
 
640
885
 
641
886
  def set_section(path, heading, body):
642
887
  """Replace the body of the last `## <heading>` section - the newest, since
643
- a plan appends a dated `## State (...)` - with `body` and one blank line.
644
- Returns False, writing nothing, when the plan has no such section. Raises
645
- OSError."""
646
- lines = _lines(path)
647
- heads = [i for i, line in enumerate(lines)
648
- if re.match(r"## %s(\s|$)" % re.escape(heading), line.strip())]
649
- if not heads:
650
- return False
651
- start = heads[-1] + 1
652
- end = next((i for i in range(start, len(lines))
653
- if lines[i].lstrip().startswith("## ")), len(lines))
654
- lines[start:end] = body.strip("\n").split("\n") + [""]
655
- _write_lines(path, lines)
656
- return True
888
+ a plan appends a dated `## State (...)` - with `body` and one blank line,
889
+ under the plans lock. Returns False, writing nothing, when the plan has no
890
+ such section. Raises OSError."""
891
+ with plans_lock(path):
892
+ lines = _lines(path)
893
+ heads = [i for i, line in enumerate(lines)
894
+ if re.match(r"## %s(\s|$)" % re.escape(heading), line.strip())]
895
+ if not heads:
896
+ return False
897
+ start = heads[-1] + 1
898
+ end = next((i for i in range(start, len(lines))
899
+ if lines[i].lstrip().startswith("## ")), len(lines))
900
+ lines[start:end] = body.strip("\n").split("\n") + [""]
901
+ _write(path, "\n".join(lines))
902
+ return True
657
903
 
658
904
 
659
905
  # ---------------------------------------------------------------- slice journal
@@ -1072,6 +1318,10 @@ def worktree_clash(root, tool, inp, parent=""):
1072
1318
  return SHARED_WORKTREE % (", ".join(clash[0][1]), clash[0][0]) if clash else None
1073
1319
 
1074
1320
 
1321
+ # `plan 093` in a brief: the plan a slice works on when its checkout says none
1322
+ _BRIEF_PLAN = re.compile(r"(?i)\bplan\s+(\d{3})\b")
1323
+
1324
+
1075
1325
  def open_slice(root, sid, *, kind="agent", host="", agent="", brief="", parent="",
1076
1326
  cwd="", repo="", task="", child=""):
1077
1327
  """Open (or re-open) the record `sid` and return it. The worktree and branch
@@ -1121,9 +1371,13 @@ def open_slice(root, sid, *, kind="agent", host="", agent="", brief="", parent="
1121
1371
  # one glob over every omp project at open, so `pending` - run on every
1122
1372
  # prompt and Stop - never globs (40 ms on 1393 project dirs, measured)
1123
1373
  rec["transcript_dir"] = _omp_session_dir(rec["parent"]) or ""
1124
- # the plan whose task was active when the slice opened: the review guard
1125
- # reads its own plan's records only
1126
- rec["plan"] = (active(main, main) or {}).get("id") or rec.get("plan") or ""
1374
+ # the slice's own plan: the review guard reads its own plan's records only.
1375
+ # Its worktree's plan branch, else the plan its task or shared brief names,
1376
+ # else the tag a re-open already carries, else the task the main checkout
1377
+ # reads - with two plans running that is only one of them
1378
+ named = _BRIEF_PLAN.search(own) or _BRIEF_PLAN.search(brief or "")
1379
+ rec["plan"] = (plan_of_branch(rec.get("branch")) or (named and named.group(1))
1380
+ or rec.get("plan") or (active(main, main) or {}).get("id") or "")
1127
1381
  rec.update(base=_git(main, "rev-parse", "HEAD") or "", status="open",
1128
1382
  failure="", updated=now)
1129
1383
  return _write_record(root, rec)
@@ -1264,12 +1518,10 @@ _ABS_PATH = re.compile(r"(?<![\w.~/-])/[^\s'\"`<>()\[\]{},;|*?]+")
1264
1518
 
1265
1519
  def main_repo(path):
1266
1520
  """The main checkout of the repository holding `path`, else None. A path
1267
- under a repository's own `.tezgah` - a private git repository of its own -
1268
- belongs to the repository around it, and a worktree to its main checkout."""
1521
+ under a repository's own `.tezgah` belongs to the repository around it
1522
+ (`_toplevel`), and a worktree to its main checkout."""
1269
1523
  from tezgah_paths import _toplevel
1270
1524
  top = _toplevel(os.path.realpath(path))
1271
- while top and os.path.basename(top) == ".tezgah":
1272
- top = _toplevel(os.path.dirname(top))
1273
1525
  return _journal_root(top) if top else None
1274
1526
 
1275
1527
 
@@ -27,7 +27,8 @@ import time
27
27
 
28
28
  import tezgah_integrity as ti
29
29
  import tezgah_paths
30
- from tezgah_paths import _toplevel, armed, root_for, workspace_from_repo
30
+ from tezgah_paths import (_linked_main_at, _toplevel, armed, root_for,
31
+ workspace_from_repo)
31
32
 
32
33
  ARM = "taste-on"
33
34
  LEARN_EVERY = 3600 # seconds between two background `learn` starts per repo
@@ -46,7 +47,8 @@ WHOLE_KEYS = ("content", "file_text", "text", "new_source")
46
47
 
47
48
  def enabled(cwd):
48
49
  """The git root taste rows for `cwd` go to, or None when capture is off
49
- there. The marker is read first, so the default costs two stats."""
50
+ there: a linked worktree's rows go to its main checkout, the one `.tezgah`
51
+ of the repository. The marker is read first, so the default costs two stats."""
50
52
  try:
51
53
  if not cwd or not armed(ARM):
52
54
  return None
@@ -54,6 +56,7 @@ def enabled(cwd):
54
56
  if not root_for(real):
55
57
  return None
56
58
  top = _toplevel(real)
59
+ top = top and (_linked_main_at(top) or top)
57
60
  if not top or not os.path.isdir(os.path.join(top, ".tezgah")):
58
61
  return None
59
62
  from tezgah_context import repo_marks # imports this module's callers
@@ -73,10 +76,11 @@ def _cut(text, limit):
73
76
 
74
77
 
75
78
  def _rel(root, path, cwd):
76
- """`path` relative to `root`, or None when it resolves outside it."""
79
+ """`path` relative to the checkout holding `cwd` (a worktree's paths read
80
+ the same as main's), or None when it resolves outside it."""
77
81
  apath = os.path.realpath(
78
82
  str(path) if os.path.isabs(str(path)) else os.path.join(cwd, str(path)))
79
- rel = os.path.relpath(apath, root)
83
+ rel = os.path.relpath(apath, _toplevel(os.path.realpath(cwd)) or root)
80
84
  return None if rel == ".." or rel.startswith(".." + os.sep) else rel
81
85
 
82
86
 
@@ -31,7 +31,7 @@ try:
31
31
  shell_kind)
32
32
  from tezgah_gate import cross_project_note, decision, graph_advice # noqa: E402
33
33
  from tezgah_integrity import ( # noqa: E402
34
- SUBAGENT_CHANNEL, bind_session, changed_files_notice, note_tool, ran_nothing,
34
+ SUBAGENT_CHANNEL, bind_session, changed_files_notice, note, note_tool, ran_nothing,
35
35
  report_bytes, stop_reason, untrusted_source)
36
36
  from tezgah_paths import HOST_DIRS, off, root_for # noqa: E402
37
37
  from tezgah_taste import write_note # noqa: E402
@@ -108,7 +108,16 @@ def gate_reason(payload, cwd, session_id):
108
108
  inp = payload.get("tool_input")
109
109
  if not isinstance(inp, dict):
110
110
  inp = {}
111
- return decision(gate_name(name), inp, cwd, session_id)
111
+ # a subagent's calls carry its `agent_id` (the child thread id; optional in
112
+ # pre-tool-use.command.input.schema.json): its repeat ceilings are its own
113
+ return decision(gate_name(name), inp, cwd, session_id, agent=subagent_id(payload))
114
+
115
+
116
+ def subagent_id(payload):
117
+ """The child thread id Codex puts on a subagent's hook input, else None (a
118
+ root-agent call; codex-rs/hooks: "Add subagent identity to hook inputs")."""
119
+ aid = payload.get("agent_id")
120
+ return aid if isinstance(aid, str) and aid else None
112
121
 
113
122
 
114
123
  def verify_outcome(payload):
@@ -196,7 +205,11 @@ def main():
196
205
  out_bytes=(report_bytes(result)
197
206
  if untrusted_source(tool, inp) == SUBAGENT_CHANNEL
198
207
  else result_size(result)),
199
- empty_run=ran_nothing(result))
208
+ empty_run=ran_nothing(result),
209
+ # the child's rows land in the root session's ledger (Codex's
210
+ # session_id is the root thread's); the route report reads them
211
+ # back by this id
212
+ agent=subagent_id(payload))
200
213
  # The gate's graph line for a code-symbol search rides the same
201
214
  # non-blocking channel (tezgah_gate.graph_advice): a Codex PreToolUse
202
215
  # carries a deny and nothing else.
@@ -220,6 +233,13 @@ def main():
220
233
  return
221
234
  if event == "SubagentStart":
222
235
  safe(session_id, record, session_id, "orch")
236
+ # the child's typing row in the root ledger: SubagentStart requires
237
+ # agent_id and agent_type (codex-rs/hooks/schema/generated/
238
+ # subagent-start.command.input.schema.json)
239
+ kind = payload.get("agent_type")
240
+ if subagent_id(payload) and isinstance(kind, str) and root_for(cwd):
241
+ safe(session_id, note, session_id, "subagent_start", kind,
242
+ agent=subagent_id(payload))
223
243
  if event == "Stop":
224
244
  out = {}
225
245
  # Codex's stop.input carries the same fields Claude's does
@@ -388,6 +388,17 @@ def dispatch(payload):
388
388
  else:
389
389
  record(session_id, "orch")
390
390
  out = {"permission": "allow"}
391
+ # The child's ledger opens with its parent, as on omp and opencode:
392
+ # a Cursor subagent runs under its own conversation id (observed with
393
+ # cursor-agent 2026.10.01), and this event names it `subagent_id`
394
+ # beside `parent_conversation_id` (Cursor hooks docs, subagentStart).
395
+ # `host` marks the row whose id the report has not seen carried by
396
+ # the child's own calls (tezgah_models.route_report).
397
+ child, parent = payload.get("subagent_id"), payload.get("parent_conversation_id")
398
+ if isinstance(child, str) and child and isinstance(parent, str) and parent \
399
+ and under(cwd):
400
+ safe(child, note, child, "spawned", "", parent=parent,
401
+ agent=str(payload.get("subagent_type") or "") or None, host="cursor")
391
402
  # The delegate is a fresh context that must know the rules exist, so
392
403
  # it gets the same short brief every other host hands a subagent.
393
404
  # Cursor documents no context field on subagentStart (its output is