@r1z4x/tezgah 1.6.0 → 1.7.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 (64) hide show
  1. package/CHANGELOG.md +80 -1
  2. package/MANIFEST +5 -0
  3. package/README.md +6 -3
  4. package/bin/tezgah-docs +18 -16
  5. package/bin/tezgah-doctor +152 -0
  6. package/bin/tezgah-research +234 -10
  7. package/bin/tezgah-route +48 -1
  8. package/bin/tezgah-setup +26 -13
  9. package/bin/tezgah-task +589 -17
  10. package/bin/tezgah-taste +48 -10
  11. package/docs/architecture.md +7 -4
  12. package/docs/clarity-baseline.json +2 -2
  13. package/docs/contract.md +108 -24
  14. package/docs/evidence.md +201 -28
  15. package/docs/feature-audit.md +1 -1
  16. package/docs/gate.md +60 -22
  17. package/docs/glossary.md +4 -4
  18. package/docs/hosts.md +8 -8
  19. package/docs/index.json +11 -5
  20. package/docs/judge.md +9 -3
  21. package/docs/models.md +35 -2
  22. package/docs/operations.md +103 -3
  23. package/docs/research.md +346 -13
  24. package/docs/skills.md +1 -1
  25. package/docs/status-line.md +5 -5
  26. package/docs/testing.md +2 -2
  27. package/docs/vision.md +12 -4
  28. package/hooks/hooks.json +2 -2
  29. package/hooks/lang/en.json +323 -0
  30. package/hooks/lang/tr.json +344 -0
  31. package/hooks/projects-posttooluse.py +24 -5
  32. package/hooks/projects-pretooluse.py +9 -4
  33. package/hooks/projects-stop.py +28 -16
  34. package/hooks/tezgah_agents.py +29 -10
  35. package/hooks/tezgah_clarity.py +7 -3
  36. package/hooks/tezgah_context.py +425 -406
  37. package/hooks/tezgah_gate.py +583 -33
  38. package/hooks/tezgah_integrity.py +1480 -197
  39. package/hooks/tezgah_models.py +337 -15
  40. package/hooks/tezgah_paths.py +103 -10
  41. package/hooks/tezgah_policy.py +258 -126
  42. package/hooks/tezgah_rank.py +29 -27
  43. package/hooks/tezgah_replay.py +200 -30
  44. package/hooks/tezgah_research.py +1294 -9
  45. package/hooks/tezgah_stopspec.py +7 -0
  46. package/hooks/tezgah_store.py +74 -27
  47. package/hooks/tezgah_task.py +915 -6
  48. package/hosts/codex/hook.py +9 -10
  49. package/hosts/cursor/hook.py +21 -14
  50. package/hosts/dsh/hooks.json +2 -2
  51. package/hosts/omp/hook.py +44 -23
  52. package/hosts/omp/tezgah-hook.ts.in +93 -7
  53. package/hosts/opencode/plugins/tezgah.js +102 -2
  54. package/output-styles/tezgah.md +76 -42
  55. package/package.json +1 -1
  56. package/skills/i-have-adhd/EVALS.md +22 -2
  57. package/skills/i-have-adhd/SKILL.md +12 -5
  58. package/skills/plan-add/SKILL.md +5 -3
  59. package/skills/plan-sync/SKILL.md +5 -3
  60. package/skills/ponytail/EVALS.md +21 -2
  61. package/skills/ponytail/SKILL.md +4 -4
  62. package/skills/research/SKILL.md +89 -1
  63. package/skills/tezgah-contract/EVALS.md +52 -7
  64. package/skills/tezgah-contract/SKILL.md +151 -56
@@ -42,7 +42,14 @@ when the tree was clean, or `pending <sha>` when it was not, `<sha>` being the
42
42
  reader of that form). The gate refuses a write in that phase only while the
43
43
  recorded sha is still HEAD and the tree is still dirty, so the commit its refusal
44
44
  names clears it by itself.
45
+
46
+ The slice journal lives here too (`open_slice` ... `slice_lines`): one row per
47
+ delegated slice in `<main checkout>/.tezgah/slices/slices.db`, written by
48
+ the post-tool path on a spawn call and by `tezgah-task slice`, so a parent
49
+ crash, an omp `/clear`, a quota stop or a host switch loses no slice. They shell
50
+ out to git and read omp transcripts; the Stop rule reads `pending` (fail open).
45
51
  """
52
+ import contextlib
46
53
  import os
47
54
  import re
48
55
 
@@ -513,20 +520,60 @@ def match(rel, pattern):
513
520
  return re.fullmatch("".join(out), rel) is not None
514
521
 
515
522
 
516
- def relative(path, cwd, base):
523
+ def relative(path, cwd, base, worktrees=False):
517
524
  """The realpath of `path` (absolute or relative to cwd) as a posix path
518
525
  relative to repo_root(cwd, base), or None when it resolves outside that root
519
- (so an outside write can never be inside the allowlist)."""
526
+ (so an outside write can never be inside the allowlist).
527
+
528
+ With `worktrees`, a linked worktree of the same repository is that
529
+ repository, not outside it: the contract gives every writing slice its own
530
+ worktree, and an allowlist that could not name `hooks/a.py` there refused the
531
+ workflow it prescribes (three slices of plan 077 hit it). A path under such a
532
+ worktree answers relative to the worktree's top. Only the allowlist asks for
533
+ this (`hooks/tezgah_gate.py::task_reason`); the record and workspace rules
534
+ keep the main-root reading, so their decisions do not move. The test is
535
+ fork-free and only a path already outside the root pays for it; a worktree of
536
+ another repository is still outside."""
520
537
  root = repo_root(cwd, base)
538
+ path = os.path.expanduser(path) # a host may spell it `~/...`
521
539
  real = os.path.realpath(path if os.path.isabs(path) else os.path.join(cwd, path))
522
540
  if real == root or not _under(real, root):
523
- # the root itself is not a path inside the root: an allowlist is about
524
- # files under it, so this is a None like any other, and every caller
525
- # fails in the refusing direction
526
- return None
541
+ top = _sibling_worktree(real, root) if worktrees else None
542
+ if top is None or real == top:
543
+ # the root itself is not a path inside the root: an allowlist is
544
+ # about files under it, so this is a None like any other, and every
545
+ # caller fails in the refusing direction
546
+ return None
547
+ root = top
527
548
  return real[len(root) + 1:].replace(os.sep, "/")
528
549
 
529
550
 
551
+ def _sibling_worktree(real, root):
552
+ """The top of the linked worktree holding `real` when it is a checkout of the
553
+ repository `root` is a checkout of, else None.
554
+
555
+ The worktree's admin directory has to point back at it (`<admin>/gitdir` is
556
+ `<top>/.git`, which git always writes): a `.git` file planted anywhere that
557
+ names a real admin directory would otherwise make any directory a sibling."""
558
+ from tezgah_paths import linked_main, worktree_top
559
+ top = worktree_top(real)
560
+ if top is None:
561
+ return None
562
+ main = linked_main(root) or root
563
+ if linked_main(top) != os.path.realpath(main):
564
+ return None
565
+ try:
566
+ with open(os.path.join(top, ".git"), encoding="utf-8") as fh:
567
+ admin = fh.readline().strip()[len("gitdir:"):].strip()
568
+ admin = os.path.normpath(os.path.join(top, admin))
569
+ with open(os.path.join(admin, "gitdir"), encoding="utf-8") as fh:
570
+ back = fh.readline().strip()
571
+ except (OSError, UnicodeDecodeError):
572
+ return None
573
+ back = os.path.normpath(os.path.join(admin, back))
574
+ return top if os.path.realpath(back) == os.path.join(top, ".git") else None
575
+
576
+
530
577
  def set_fields(path, **fields):
531
578
  """Rewrite these frontmatter keys in the plan file in place: replace the
532
579
  `key: value` line when present, insert it before the closing `---` when not.
@@ -570,3 +617,865 @@ def _field_lines(key, value):
570
617
  if value is None or value == "":
571
618
  return [key + ":"]
572
619
  return ["%s: %s" % (key, value)]
620
+
621
+
622
+ def _lines(path):
623
+ with open(path, "r", encoding="utf-8") as fh:
624
+ return fh.read().split("\n")
625
+
626
+
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)
639
+
640
+
641
+ def set_section(path, heading, body):
642
+ """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
657
+
658
+
659
+ # ---------------------------------------------------------------- slice journal
660
+ #
661
+ # One row per delegated slice in `<main checkout>/.tezgah/slices/slices.db`,
662
+ # so a parent crash, an omp `/clear`, a quota stop or a model or host switch
663
+ # loses no slice: the record names the brief, the worktree and branch it works
664
+ # in, and how it ended. Written by the post-tool path on a spawn call
665
+ # (`note_spawn`) and by `tezgah-task slice`; read by the context line
666
+ # (`slice_lines`), the Stop rule and `tezgah-task slice reconcile|resume|stats`.
667
+ # Every reader fails open: an unreadable record is skipped, never a refusal.
668
+ # These functions shell out to git; none of them is on the gate's write path.
669
+
670
+ SLICE_KINDS = ("agent", "job")
671
+ SLICE_HOSTS = ("omp", "claude", "codex", "cursor", "opencode", "dsh")
672
+ # `stale`: no terminal status was observed within SLICE_IDLE_SECONDS - a host
673
+ # that reports none (Codex `spawn_agent` returns at once and tezgah sees no child
674
+ # event) or a child that died without writing its end. It leaves the pending
675
+ # list without being read as finished or as failed.
676
+ SLICE_STATUSES = ("open", "done", "stopped", "resumed", "stale")
677
+ SLICE_CLOSED = ("done", "stopped", "resumed", "stale")
678
+ SLICE_IDLE_SECONDS = 6 * 3600
679
+ FAILURES = ("quota", "rate_limit", "aborted", "timeout", "cut_off", "error")
680
+ SLICE_BRIEF = 4000
681
+ SLICE_LINE = 160
682
+ SLICE_TAIL = 64 * 1024
683
+ _SLICE_ID = re.compile(r"[^A-Za-z0-9._-]+")
684
+ # Failure classes, first match wins, in this order. The table moved here from
685
+ # the research line's measure (data/E2-subagent-stops/measure.py::CLASSES) and
686
+ # was narrowed to the classes a lever exists for (`tezgah-task slice stats`).
687
+ _CLASSES = (
688
+ ("rate_limit", re.compile(r"\b429\b|rate[ _-]?limit", re.I)),
689
+ ("quota", re.compile(r"quota|key limit exceeded|usage limit|"
690
+ r"\b40[23]\b.*\blimit|\blimit\b.*\b40[23]\b", re.I)),
691
+ ("aborted", re.compile(r"abort|cancell?ed", re.I)),
692
+ ("timeout", re.compile(r"timed out|timeout", re.I)),
693
+ ("cut_off", re.compile(r"max[ _]turns|max_tokens|cut off|context[ _]length",
694
+ re.I)),
695
+ )
696
+
697
+
698
+ def _now():
699
+ import time
700
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
701
+
702
+
703
+ def _git(cwd, *args):
704
+ """One git command's stdout in `cwd`, or None when git cannot answer."""
705
+ import subprocess
706
+ try:
707
+ out = subprocess.run(["git", "-C", cwd] + list(args), capture_output=True,
708
+ text=True, timeout=10)
709
+ except (OSError, subprocess.SubprocessError):
710
+ return None
711
+ return out.stdout.strip() if out.returncode == 0 else None
712
+
713
+
714
+ def _journal_root(root):
715
+ """The main checkout of `root`'s repository: every worktree of one repository
716
+ writes one journal, so a slice opened from a worktree is still found from the
717
+ main checkout the parent session runs in."""
718
+ from tezgah_paths import linked_main
719
+ real = os.path.realpath(root)
720
+ return linked_main(real) or real
721
+
722
+
723
+ def slice_dir(root):
724
+ """`<main checkout>/.tezgah/slices`, the one directory the records live in."""
725
+ return os.path.join(_journal_root(root), ".tezgah", "slices")
726
+
727
+
728
+ def _digest(text):
729
+ import hashlib
730
+ return hashlib.sha1(text.encode("utf-8", "replace")).hexdigest()[:12]
731
+
732
+
733
+ def slice_id(name, brief=""):
734
+ """A record id: `name` cut to [A-Za-z0-9._-] and 80 characters, else the
735
+ first 12 hex digits of the brief's sha1 - so a re-dispatch of the same brief
736
+ re-opens the same record and counts an attempt."""
737
+ sid = _SLICE_ID.sub("-", str(name or "")).strip("-.")[:80]
738
+ return sid or _digest(brief)
739
+
740
+
741
+ # The journal is one SQLite file opened like the taste store
742
+ # (`tezgah_store.connect`: WAL, a busy timeout, 0600). One JSON file per record
743
+ # piled up a file per subagent in every repository (owner, 2026-10-11). `row`
744
+ # is the record as JSON; `parent`, `status` and `updated` are the columns a
745
+ # reader filters and sorts on, and `seq` orders two writes in one second.
746
+ SLICE_DB = "slices.db"
747
+ SLICE_SCHEMA = """
748
+ CREATE TABLE IF NOT EXISTS slices (id TEXT PRIMARY KEY, parent TEXT NOT NULL,
749
+ status TEXT NOT NULL, updated TEXT NOT NULL,
750
+ seq INTEGER NOT NULL, row TEXT NOT NULL);
751
+ """
752
+
753
+
754
+ @contextlib.contextmanager
755
+ def _journal(root, create=True):
756
+ """The journal database of `root`'s repository, the `*.json` records an
757
+ older release wrote imported first (`_import_json`). With `create` False it
758
+ yields None where neither the database nor an old record exists, so a read
759
+ leaves no file behind. The directory carries a `.gitignore` of `*`: the
760
+ records are runtime state, never part of the private `.tezgah` history."""
761
+ import glob
762
+ import tezgah_store
763
+ folder = slice_dir(root)
764
+ path = os.path.join(folder, SLICE_DB)
765
+ if not create and not os.path.exists(path) and not glob.glob(
766
+ os.path.join(folder, "*.json")):
767
+ yield None
768
+ return
769
+ os.makedirs(folder, mode=0o700, exist_ok=True)
770
+ ignore = os.path.join(folder, ".gitignore")
771
+ if not os.path.exists(ignore):
772
+ with open(ignore, "w", encoding="utf-8") as fh:
773
+ fh.write("*\n")
774
+ conn = tezgah_store.connect(path, SLICE_SCHEMA, version=1)
775
+ try:
776
+ _import_json(conn, folder)
777
+ yield conn
778
+ finally:
779
+ conn.close()
780
+
781
+
782
+ def _import_json(conn, folder):
783
+ """Bring the one-file-per-record journal into the table, then delete each
784
+ file it took. A file whose `updated` is newer than the row replaces it - an
785
+ older release still running writes files beside the database - and an
786
+ older one leaves the row alone. A file that does not parse, or whose id is
787
+ not a string, stays where it is. With no old file, one glob."""
788
+ import glob
789
+ import json
790
+ for path in glob.glob(os.path.join(folder, "*.json")):
791
+ try:
792
+ with open(path, encoding="utf-8") as fh:
793
+ rec = json.load(fh)
794
+ except (OSError, ValueError):
795
+ continue
796
+ if not isinstance(rec, dict) or not isinstance(rec.get("id"), str) \
797
+ or not rec["id"]:
798
+ continue
799
+ held = conn.execute("SELECT updated FROM slices WHERE id = ?",
800
+ (rec["id"],)).fetchone()
801
+ if held is None or str(rec.get("updated") or "") > held[0]:
802
+ _put(conn, rec)
803
+ try:
804
+ os.remove(path)
805
+ except OSError:
806
+ pass
807
+
808
+
809
+ def _put(conn, rec):
810
+ """Upsert one record."""
811
+ import json
812
+ conn.execute(
813
+ "INSERT OR REPLACE INTO slices (id, parent, status, updated, seq, row) "
814
+ "VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(seq), 0) + 1 FROM slices), ?)",
815
+ (rec["id"], str(rec.get("parent") or ""), str(rec.get("status") or ""),
816
+ str(rec.get("updated") or ""), json.dumps(rec, sort_keys=True)))
817
+
818
+
819
+ def _row(text):
820
+ import json
821
+ try:
822
+ rec = json.loads(text)
823
+ except ValueError:
824
+ return None
825
+ return rec if isinstance(rec, dict) and rec.get("id") else None
826
+
827
+
828
+ def _read_record(root, sid):
829
+ """The record `sid`, or None; a journal that cannot be opened reads as none."""
830
+ import tezgah_store
831
+ try:
832
+ with _journal(root, create=False) as db:
833
+ hit = db and db.execute("SELECT row FROM slices WHERE id = ?",
834
+ (slice_id(sid),)).fetchone()
835
+ except tezgah_store.ERRORS:
836
+ return None
837
+ return _row(hit[0]) if hit else None
838
+
839
+
840
+ def _write_record(root, rec):
841
+ """Write one record (one upsert statement) and return it. A database that
842
+ cannot be opened or written raises OSError, the error every caller already
843
+ handles (`tezgah-task slice` prints it, the hooks swallow it)."""
844
+ import tezgah_store
845
+ try:
846
+ with _journal(root) as db:
847
+ _put(db, rec)
848
+ except tezgah_store.ERRORS as exc:
849
+ raise OSError("slice journal %s: %s" % (slice_dir(root), exc)) from exc
850
+ return rec
851
+
852
+
853
+ def _own_id(root, base, brief, parent):
854
+ """The record id a spawn of `base` opens: `base` itself, unless another
855
+ session holds that record under another brief. Then it is this session's
856
+ own record of that name, else `base-<digest6>` of the brief, so another
857
+ session's stopped slice stays its owner's (research orchestrator-flow-audit
858
+ S7). A same-brief re-dispatch from another session still re-opens `base`."""
859
+ held = _read_record(root, base)
860
+ if (held is None or not parent or not brief
861
+ or held.get("parent") in ("", None, parent)
862
+ or held.get("brief") == brief):
863
+ return base
864
+ own = next((r["id"] for r in slices(root) if r.get("parent") == parent
865
+ and (r.get("name") or r["id"]) == base), None)
866
+ return own or "%s-%s" % (base, _digest(brief)[:6])
867
+
868
+
869
+ def find_slice(root, ref, session=None):
870
+ """The record `ref` names for `tezgah-task slice done|stop|resume`. An
871
+ exact id answers whatever session owns it: an id is unambiguous, and it is
872
+ what `slice reconcile` and the review guard print, so a crashed session's
873
+ slice stays reachable. A bare spawned `name` is a lookup: with `session`
874
+ (TEZGAH_SESSION), the newest record of that name the session spawned or no
875
+ session owns; without one, the one record of that name. None when nothing
876
+ answers."""
877
+ ref = slice_id(ref)
878
+ recs = slices(root)
879
+ exact = next((r for r in recs if r["id"] == ref), None)
880
+ if exact or session:
881
+ return exact or next((r for r in recs if r.get("name") == ref
882
+ and r.get("parent") in (session, "", None)), None)
883
+ named = [r for r in recs if r.get("name") == ref]
884
+ return named[0] if len(named) == 1 else None
885
+
886
+
887
+ def _worktree_in(root, *texts):
888
+ """The linked worktree of this repository the first of `texts` that names
889
+ one names, else "": a slice's own task before the batch's shared context
890
+ (which may list every slice's worktree), the one named last in a text, and
891
+ on a tie the longer path, since `.../a` is found inside `.../a-b` at the
892
+ same place. What reconcile, resume, the board and merge-plan read."""
893
+ from tezgah_paths import worktrees
894
+ trees = worktrees(root)[1:]
895
+ for text in texts:
896
+ found = [p for p in trees if text and p in text]
897
+ if found:
898
+ return max(found, key=lambda p: (text.rfind(p), len(p)))
899
+ return ""
900
+
901
+
902
+ def _named_worktree(root, *texts):
903
+ """(worktree, branch) for the linked worktree `_worktree_in` reads off
904
+ `texts`, else ("", "")."""
905
+ top = _worktree_in(root, *texts)
906
+ return (top, _git(top, "symbolic-ref", "-q", "--short", "HEAD") or "") if top else ("", "")
907
+
908
+
909
+ # Where a brief says its slice works: a `Worktree:`, `Checkout` or `Cwd:` line
910
+ # opening with an absolute path. A path merely mentioned - a worktree to remove,
911
+ # a sibling's tree, a file to read - is no declaration (review of S6, F2). Only
912
+ # the main-checkout refusal reads it (`tezgah_gate.lease_reason`).
913
+ _DECLARED = re.compile(r"(?im)^[ \t>#*_-]*(?:worktree|checkout|cwd)\b[*_]*[ \t]*:?"
914
+ r"[*_]*[ \t]*`?(/[^\s`'\"()<>,;]+)")
915
+
916
+
917
+ def _declared_in(root, *texts):
918
+ """The linked worktree of this repository the first of `texts` that
919
+ declares a checkout of it declares, else "": a slice's own task before the
920
+ batch's shared context, the last declaration in a text. A declared main
921
+ checkout is none - an integrator works there."""
922
+ from tezgah_paths import worktrees
923
+ trees = worktrees(root)
924
+ for text in texts:
925
+ found = [os.path.realpath(p.rstrip("/.:")) for p in _DECLARED.findall(text or "")]
926
+ found = [p for p in found if p in trees]
927
+ if found:
928
+ return found[-1] if found[-1] != trees[0] else ""
929
+ return ""
930
+
931
+
932
+ # `Owns:` in a slice's own task: the repository-relative globs only that slice
933
+ # writes while its record is open (research orchestrator-flow-audit S6, pick
934
+ # V2). The gate refuses a sibling's write into them (`tezgah_gate.lease_reason`).
935
+ # The globs follow on the line, split on commas and spaces, or - with nothing
936
+ # after the colon - as the `- glob` list items under it. Markdown emphasis and
937
+ # backticks are dropped; an absolute glob under the slice's checkout is cut to it.
938
+ _OWNS = re.compile(r"(?i)^[ \t>#*_-]*owns[*_]*[ \t]*:[*_]*[ \t]*(.*)$")
939
+ _LIST_ITEM = re.compile(r"^[ \t>]*(?:[-*+]|\d+[.)])[ \t]+(.+)$")
940
+
941
+
942
+ def owned_globs(text, tops=()):
943
+ """The `Owns:` globs `text` names, in order, each once."""
944
+ found, lines = [], (text or "").splitlines()
945
+ for i, line in enumerate(lines):
946
+ hit = _OWNS.match(line)
947
+ if not hit:
948
+ continue
949
+ if hit.group(1).strip():
950
+ found.append(hit.group(1))
951
+ continue
952
+ for item in lines[i + 1:]:
953
+ listed = _LIST_ITEM.match(item)
954
+ if not listed:
955
+ break
956
+ found.append(listed.group(1))
957
+ out = []
958
+ for line in found:
959
+ for glob in re.split(r"[\s,]+", line.replace("`", "")):
960
+ glob = glob.rstrip(".;")
961
+ top = next((t for t in tops if t and glob.startswith(t + "/")), "")
962
+ glob = glob[len(top) + 1:] if top else glob
963
+ if glob and glob not in out:
964
+ out.append(glob)
965
+ return out
966
+
967
+
968
+ def slice_of(root, parent, name):
969
+ """The newest record `parent` spawned under `name` - a subagent's own
970
+ record, read from its `spawned` row (`tezgah_integrity.spawn_of`), or from
971
+ Claude's `agent_id`, which a background spawn stores as `child` - else
972
+ None. An exact name or child first; omp names a re-dispatch `<name>-2`, so
973
+ the bare name answers only after that."""
974
+ if not (parent and name):
975
+ return None
976
+ mine = [r for r in slices(root) if r.get("parent") == parent]
977
+ base = re.sub(r"-\d+$", "", name)
978
+ return (next((r for r in mine if (r.get("name") or r["id"]) == name
979
+ or r.get("child") == name), None)
980
+ or next((r for r in mine if (r.get("name") or r["id"]) == base), None))
981
+
982
+
983
+ def owner_of(root, path, parent, me):
984
+ """The open record of another writing slice `parent` spawned whose `owns`
985
+ matches the repository-relative `path`, else None; `me` is the writing
986
+ slice's id. A reader - an agent named for a read-only role - holds no
987
+ lease, whatever `Owns:` line its task quotes. A hit re-reads after
988
+ `pending`, which closes an omp sibling whose child transcript ended, so a
989
+ finished owner frees its files."""
990
+ def held():
991
+ return next((r for r in slices(root) if parent and r.get("parent") == parent
992
+ and r.get("status") == "open" and r["id"] != me
993
+ and not _READ_ONLY_AGENT.search(r.get("agent") or "")
994
+ and any(match(path, g) for g in r.get("owns") or ())), None)
995
+ if held() is None:
996
+ return None
997
+ pending(root)
998
+ return held()
999
+
1000
+
1001
+ def close_child(root, parent, child):
1002
+ """Close as done the open record `parent` spawned whose `child` is the
1003
+ host's id of a background child that just ended (Claude's SubagentStop),
1004
+ and return it; None when no record holds it."""
1005
+ rec = next((r for r in slices(root) if parent and child and r.get("parent") == parent
1006
+ and r.get("child") == child and r.get("status") == "open"), None)
1007
+ return close_slice(root, rec["id"], "done") if rec else None
1008
+
1009
+
1010
+ BOARD_LINES = 12
1011
+
1012
+
1013
+ def board(root, parent=None, limit=BOARD_LINES):
1014
+ """One bounded line (SLICE_LINE) per open record `parent` spawned - every
1015
+ open record without one - newest first, at most `limit` and a count of the
1016
+ rest: what each slice owns, where it works and its last commit. A pull
1017
+ read: siblings see each other's leases here instead of by message."""
1018
+ recs = [r for r in pending(root) if r.get("status") == "open"
1019
+ and r.get("kind") != "job" and (parent is None or r.get("parent") == parent)]
1020
+ out = []
1021
+ for rec in recs[:limit]:
1022
+ tree = rec.get("worktree") or ""
1023
+ head = _git(tree, "log", "-1", "--format=%h") if tree and os.path.isdir(tree) else None
1024
+ out.append(_cut("- %s (%s/%s) owns %s; worktree %s on %s @ %s" % (
1025
+ rec.get("name") or rec["id"], rec.get("host") or "?", rec.get("agent") or "?",
1026
+ ", ".join(rec.get("owns") or ()) or "nothing declared", tree or "none",
1027
+ rec.get("branch") or "?", head or "?")))
1028
+ if len(recs) > limit:
1029
+ out.append("- %d more open slice(s)" % (len(recs) - limit))
1030
+ return out
1031
+
1032
+
1033
+ # An agent whose name reads as a read-only role shares a worktree freely: a
1034
+ # reviewer panel on one slice's checkout is the allowed case (research
1035
+ # orchestrator-flow-audit S6, finding 5). A brief with `Owns:` writes whatever
1036
+ # its agent is called.
1037
+ _READ_ONLY_AGENT = re.compile(r"review|explor|research|scout|audit|critic|judge", re.I)
1038
+ SHARED_WORKTREE = (
1039
+ "tezgah: writing slices %s all name worktree %s. Every writing slice gets its own "
1040
+ "branch and checkout (`orca worktree create` inside Orca, else `git worktree "
1041
+ "add`): two slices in one checkout overwrite each other's edits.")
1042
+
1043
+
1044
+ def _writing(agent, owns):
1045
+ return bool(owns) or not _READ_ONLY_AGENT.search(agent or "")
1046
+
1047
+
1048
+ def _writes(rec):
1049
+ """A writing record: its own task's `Owns:`, else an agent not named for a
1050
+ read-only role. A reviewer whose lease came from a shared brief is not one."""
1051
+ return _writing(rec.get("agent"), owned_globs(rec.get("task") or ""))
1052
+
1053
+
1054
+ def worktree_clash(root, tool, inp, parent=""):
1055
+ """SHARED_WORKTREE when two writing slices - of this spawn call, or this
1056
+ call and an open record `parent` spawned - name one worktree, else None.
1057
+ Each task's own text decides its worktree; one that names none takes the
1058
+ shared context's, which is how ten slices landed in one checkout."""
1059
+ main = _journal_root(root)
1060
+ seen = {}
1061
+ for rec in slices(root):
1062
+ if parent and rec.get("parent") == parent and rec.get("status") == "open" \
1063
+ and rec.get("worktree") and _writes(rec):
1064
+ seen.setdefault(rec["worktree"], set()).add(rec.get("name") or rec["id"])
1065
+ context = str(inp.get("context") or "").strip() if isinstance(inp, dict) else ""
1066
+ for sid, agent, brief in spawned(tool, inp):
1067
+ task = brief[len(context):] if context else brief
1068
+ tree = _worktree_in(main, task, brief)
1069
+ if tree and _writing(agent, owned_globs(task)):
1070
+ seen.setdefault(tree, set()).add(sid)
1071
+ clash = sorted((t, sorted(n)) for t, n in seen.items() if len(n) > 1)
1072
+ return SHARED_WORKTREE % (", ".join(clash[0][1]), clash[0][0]) if clash else None
1073
+
1074
+
1075
+ def open_slice(root, sid, *, kind="agent", host="", agent="", brief="", parent="",
1076
+ cwd="", repo="", task="", child=""):
1077
+ """Open (or re-open) the record `sid` and return it. The worktree and branch
1078
+ are the ones the slice's own task names (else the brief, `_worktree_in`);
1079
+ `declared` is the worktree a `Worktree:`/`Checkout`/`Cwd:` line declares
1080
+ (`_declared_in`); the base is the main checkout's HEAD, and `owns` holds the
1081
+ `Owns:` globs of the task (else the brief, `owned_globs`). A re-open
1082
+ of an existing id counts one more attempt, sets the status back to open and
1083
+ clears the failure; a field the new call leaves empty keeps its old value.
1084
+ A record another session holds under another brief is not re-opened: the
1085
+ spawn gets its own id (`_own_id`), and `name` keeps the spawned name.
1086
+ `repo` names the repository the slice works in when that is not this
1087
+ journal's (`_brief_repo`); `child` is the host's id for the running child
1088
+ (Claude's background `agentId`), the key its own writes carry."""
1089
+ base = slice_id(sid, brief)
1090
+ main = _journal_root(root)
1091
+ own = task or brief or ""
1092
+ brief = (brief or "")[:SLICE_BRIEF]
1093
+ now = _now()
1094
+ sid = _own_id(root, base, brief, parent)
1095
+ rec = _read_record(root, sid)
1096
+ if rec is None:
1097
+ rec = {"id": sid, "name": base, "kind": kind if kind in SLICE_KINDS else "agent",
1098
+ "host": host, "agent": agent, "brief": brief, "parent": parent,
1099
+ "cwd": cwd, "repo": repo, "worktree": "", "branch": "", "attempts": 1,
1100
+ "opened": now, "child": child}
1101
+ else:
1102
+ rec["attempts"] = int(rec.get("attempts") or 0) + 1
1103
+ if parent and parent != rec.get("parent"):
1104
+ # a re-dispatch from another session (after a /clear, a crash, a
1105
+ # resume) writes its child under that session's directory: the old
1106
+ # one holds the first run, whose end must not close this record
1107
+ rec["transcript_dir"] = ""
1108
+ for key, value in (("host", host), ("agent", agent), ("brief", brief),
1109
+ ("parent", parent), ("cwd", cwd), ("repo", repo),
1110
+ ("child", child)):
1111
+ if value:
1112
+ rec[key] = value
1113
+ if task: # the slice's own task, without a batch's shared context
1114
+ rec["task"] = task[:SLICE_BRIEF]
1115
+ if brief or not rec.get("worktree"):
1116
+ rec["worktree"], rec["branch"] = _named_worktree(main, task, rec.get("brief"))
1117
+ rec["declared"] = _declared_in(main, task, rec.get("brief"))
1118
+ if own:
1119
+ rec["owns"] = owned_globs(own, (rec["worktree"], main))
1120
+ if rec.get("host") == "omp" and rec.get("parent") and not rec.get("transcript_dir"):
1121
+ # one glob over every omp project at open, so `pending` - run on every
1122
+ # prompt and Stop - never globs (40 ms on 1393 project dirs, measured)
1123
+ 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 ""
1127
+ rec.update(base=_git(main, "rev-parse", "HEAD") or "", status="open",
1128
+ failure="", updated=now)
1129
+ return _write_record(root, rec)
1130
+
1131
+
1132
+ def close_slice(root, sid, status, failure="", stop=None):
1133
+ """Close the record `sid` as done, stopped or resumed; None when it has none.
1134
+ A stopped record takes `failure` (a FAILURES class or ""); done and resumed
1135
+ keep the class the slice stopped with, so the stats still count the event.
1136
+ A quota or rate-limit stop whose message `stop` ({errorMessage, provider,
1137
+ model, default}) names its family holds that family down
1138
+ (`tezgah_models.note_limit`; a rate limit briefly);
1139
+ that push fails open."""
1140
+ if status not in SLICE_CLOSED:
1141
+ raise ValueError("status %r is not one of %s" % (status, ", ".join(SLICE_CLOSED)))
1142
+ rec = _read_record(root, sid)
1143
+ if rec is None:
1144
+ return None
1145
+ if status == "stopped" or failure:
1146
+ rec["failure"] = failure if failure in FAILURES else ""
1147
+ rec.update(status=status, updated=_now())
1148
+ if failure in ("quota", "rate_limit") and isinstance(stop, dict):
1149
+ try:
1150
+ import tezgah_models
1151
+ tezgah_models.note_limit(
1152
+ stop.get("errorMessage") or "", stop.get("provider") or "",
1153
+ stop.get("model") or "", transient=failure == "rate_limit",
1154
+ default=stop.get("default") or "")
1155
+ except Exception: # noqa: BLE001 - health is advice: the close still lands
1156
+ pass
1157
+ return _write_record(root, rec)
1158
+
1159
+
1160
+ def slices(root):
1161
+ """Every readable record, newest `updated` first (the later write first on a
1162
+ tie); a row that does not parse, or a journal that cannot be opened, is
1163
+ skipped - readers fail open."""
1164
+ import tezgah_store
1165
+ try:
1166
+ with _journal(root, create=False) as db:
1167
+ rows = db.execute("SELECT row FROM slices ORDER BY updated DESC, "
1168
+ "seq DESC").fetchall() if db else []
1169
+ except tezgah_store.ERRORS:
1170
+ return []
1171
+ return [rec for rec in (_row(r[0]) for r in rows) if rec]
1172
+
1173
+
1174
+ def classify(text):
1175
+ """The failure class of an error or result text: rate_limit, quota, aborted,
1176
+ timeout, cut_off, else error for any other non-empty text, "" for none."""
1177
+ text = str(text or "").strip()
1178
+ if not text:
1179
+ return ""
1180
+ for name, pattern in _CLASSES:
1181
+ if pattern.search(text):
1182
+ return name
1183
+ return "error"
1184
+
1185
+
1186
+ def _brief(*parts):
1187
+ return "\n\n".join(str(p).strip() for p in parts if str(p or "").strip())
1188
+
1189
+
1190
+ def spawned(tool, inp):
1191
+ """[(id, agent, brief)] for one spawn call's input, [] for any other tool.
1192
+ omp `task` carries a batch ({tasks: [{name, agent, task}], context}, brief =
1193
+ context + task); Claude, dsh and Cursor `Agent`/`Task` and opencode `task`
1194
+ carry one ({description, prompt, subagent_type}); Codex `spawn_agent` carries
1195
+ {task_name, message|prompt, agent_type|agent} - its `task_name` is the id the
1196
+ user addresses the child by."""
1197
+ if not isinstance(inp, dict):
1198
+ return []
1199
+ name = str(tool or "").lower()
1200
+ out = []
1201
+ if name == "task" and isinstance(inp.get("tasks"), list):
1202
+ context = inp.get("context") or ""
1203
+ for item in inp["tasks"]:
1204
+ if not isinstance(item, dict):
1205
+ continue
1206
+ brief = _brief(context, item.get("task") or "")
1207
+ out.append((slice_id(item.get("name"), brief), str(item.get("agent") or ""),
1208
+ brief))
1209
+ elif name in ("task", "agent"):
1210
+ brief = _brief(inp.get("description") or "", inp.get("prompt") or "")
1211
+ out.append((slice_id(inp.get("name"), brief),
1212
+ str(inp.get("subagent_type") or ""), brief))
1213
+ elif name == "spawn_agent":
1214
+ brief = str(inp.get("message") or inp.get("prompt") or "")
1215
+ out.append((slice_id(inp.get("task_name") or inp.get("name"), brief),
1216
+ str(inp.get("agent_type") or inp.get("agent") or ""), brief))
1217
+ return [row for row in out if row[2]]
1218
+
1219
+
1220
+ def note_spawn(cwd, session_id, host, tool, inp, failed=None, result_text="",
1221
+ child=""):
1222
+ """Open one record per slice a spawn call started, and return them. A host
1223
+ that runs the subagent synchronously (Claude's PostToolUse on Agent/Task)
1224
+ passes `failed` as a bool and the record closes at once: stopped with the
1225
+ class of `result_text` (error when it names none), else done. No-op ([])
1226
+ under `slices-off` or outside a git checkout. The record stays in cwd's
1227
+ journal, the one the owner's Stop rule, resume block and review guard
1228
+ read, and names the repository its brief works in as `repo`
1229
+ (`_brief_repo`) when that is another one. `child` is the id the host
1230
+ returned for a background child (Claude's `agentId`)."""
1231
+ from tezgah_paths import _toplevel, off
1232
+ if off("slices-off") or not cwd:
1233
+ return []
1234
+ root = _toplevel(os.path.realpath(cwd))
1235
+ if not root:
1236
+ return []
1237
+ here = _journal_root(root)
1238
+ out = []
1239
+ context = str(inp.get("context") or "").strip() if isinstance(inp, dict) else ""
1240
+ for sid, agent, brief in spawned(tool, inp):
1241
+ repo = _brief_repo(brief, here)
1242
+ rec = open_slice(root, sid, host=host, agent=agent, brief=brief,
1243
+ parent=session_id or "", cwd=cwd,
1244
+ repo="" if repo == here else repo,
1245
+ task=brief[len(context):].strip() if context else brief,
1246
+ child=child)
1247
+ if failed is True:
1248
+ # a Claude subagent runs on Anthropic unless its model names another
1249
+ # vendor; another host's input may name a model
1250
+ stop = {"errorMessage": result_text, "model": str(inp.get("model") or ""),
1251
+ "default": "anthropic" if host == "claude" else ""}
1252
+ rec = close_slice(root, rec["id"], "stopped",
1253
+ classify(result_text) or "error", stop)
1254
+ elif failed is False:
1255
+ rec = close_slice(root, rec["id"], "done")
1256
+ out.append(rec)
1257
+ return out
1258
+
1259
+
1260
+ # An absolute path in a brief: a slash after a space, a quote or a bracket, up
1261
+ # to the next space, quote, bracket or separator.
1262
+ _ABS_PATH = re.compile(r"(?<![\w.~/-])/[^\s'\"`<>()\[\]{},;|*?]+")
1263
+
1264
+
1265
+ def main_repo(path):
1266
+ """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."""
1269
+ from tezgah_paths import _toplevel
1270
+ top = _toplevel(os.path.realpath(path))
1271
+ while top and os.path.basename(top) == ".tezgah":
1272
+ top = _toplevel(os.path.dirname(top))
1273
+ return _journal_root(top) if top else None
1274
+
1275
+
1276
+ def _brief_repo(brief, root):
1277
+ """The main checkout of the one armed repository every absolute path the
1278
+ brief names lies in, else `root`. A brief naming two repositories, or none,
1279
+ keeps `root` (research orchestrator-flow-audit S7, finding 2)."""
1280
+ from tezgah_paths import root_for
1281
+ found = set()
1282
+ for path in _ABS_PATH.findall(brief or ""):
1283
+ top = main_repo(path.rstrip(".:"))
1284
+ if top and root_for(top):
1285
+ found.add(top)
1286
+ return found.pop() if len(found) == 1 else root
1287
+
1288
+
1289
+ def _omp_session_dir(parent):
1290
+ """The omp session directory of parent session `parent`
1291
+ (`~/.omp/agent/sessions/<project>/<stamp>_<parent>/`, one `<agent id>.jsonl`
1292
+ per subagent), or None. One glob over every project, so it runs once per
1293
+ record (`open_slice`), never per prompt."""
1294
+ import glob
1295
+ if not parent or "/" in parent or os.sep in parent:
1296
+ return None
1297
+ home = os.path.expanduser("~")
1298
+ hits = glob.glob(os.path.join(home, ".omp", "agent", "sessions", "*",
1299
+ "*_" + glob.escape(parent)))
1300
+ return hits[0] if hits else None
1301
+
1302
+
1303
+ def _omp_transcript(rec):
1304
+ """The newest omp child transcript of the record: omp names a re-dispatch of
1305
+ the same agent name in one session `<name>-2.jsonl`, `<name>-3.jsonl`, so the
1306
+ highest suffix is the attempt the record now tracks - reading `<name>.jsonl`
1307
+ would close a re-opened record with the first run's end. None when the
1308
+ record names no session directory or the directory holds no such file. The
1309
+ file is named by the spawned `name`, which a record's own id may extend."""
1310
+ folder, sid = rec.get("transcript_dir"), rec.get("name") or rec.get("id")
1311
+ if not folder or not sid:
1312
+ return None
1313
+ try:
1314
+ names = os.listdir(folder)
1315
+ except OSError:
1316
+ return None
1317
+ best, rank = None, -1
1318
+ attempt = re.compile(re.escape(sid) + r"(?:-(\d+))?\.jsonl")
1319
+ for name in names:
1320
+ hit = attempt.fullmatch(name)
1321
+ n = int(hit.group(1) or 1) if hit else -1
1322
+ if n > rank:
1323
+ best, rank = os.path.join(folder, name), n
1324
+ return best
1325
+
1326
+
1327
+ def _transcript_end(path):
1328
+ """How an omp child transcript ended, read from its last SLICE_TAIL bytes:
1329
+ ("stopped", class) for an error, abort or length stop on the last assistant
1330
+ message, an error with that message third (`close_slice`'s `stop`);
1331
+ ("done", "") once it yielded, stopped normally or exited; None while
1332
+ it may still be running or cannot be read."""
1333
+ import json
1334
+ try:
1335
+ with open(path, "rb") as fh:
1336
+ fh.seek(0, os.SEEK_END)
1337
+ size = fh.tell()
1338
+ fh.seek(max(0, size - SLICE_TAIL))
1339
+ data = fh.read().decode("utf-8", "replace")
1340
+ except OSError:
1341
+ return None
1342
+ rows = []
1343
+ for line in data.splitlines():
1344
+ try:
1345
+ row = json.loads(line)
1346
+ except ValueError:
1347
+ continue # the first line of a cut tail, or a torn last one
1348
+ if isinstance(row, dict):
1349
+ rows.append(row)
1350
+ exited = yielded = False
1351
+ for row in reversed(rows):
1352
+ if row.get("type") == "custom" and row.get("customType") == "session_exit":
1353
+ exited = True
1354
+ msg = row.get("message") if isinstance(row.get("message"), dict) else None
1355
+ if not msg:
1356
+ continue
1357
+ if msg.get("role") == "toolResult" and msg.get("toolName") in ("yield",
1358
+ "submit_result"):
1359
+ yielded = True
1360
+ if msg.get("role") != "assistant":
1361
+ continue
1362
+ stop = msg.get("stopReason")
1363
+ if stop == "error":
1364
+ return "stopped", classify(msg.get("errorMessage")) or "error", msg
1365
+ if stop == "aborted":
1366
+ return "stopped", "aborted"
1367
+ if stop == "length":
1368
+ return "stopped", "cut_off"
1369
+ if yielded or exited or stop == "stop":
1370
+ return "done", ""
1371
+ return None
1372
+ return ("done", "") if yielded or exited else None
1373
+
1374
+
1375
+ # How long a `job` record stays pending: omp clamps a background call's
1376
+ # deadline to 3600 s, so a job opened longer ago has ended either way and its
1377
+ # line would only repeat itself on every prompt. Its result stays in the parent
1378
+ # transcript the record names, so it closes `stale`, not done.
1379
+ SLICE_JOB_SECONDS = 3600
1380
+
1381
+
1382
+ def _age(rec):
1383
+ """Seconds since the record last changed (`updated`, else `opened`), or None
1384
+ when neither reads as a record time."""
1385
+ import calendar
1386
+ import time
1387
+ stamp = str(rec.get("updated") or rec.get("opened") or "")
1388
+ try:
1389
+ then = calendar.timegm(time.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ"))
1390
+ except ValueError:
1391
+ return None
1392
+ return time.time() - then
1393
+
1394
+
1395
+ def pending(root):
1396
+ """The records still owed work - status open or stopped - newest first. An
1397
+ omp record still open whose child transcript has ended is closed first:
1398
+ stopped with its failure class, or done when it ended normally. An open
1399
+ record nothing has moved for SLICE_IDLE_SECONDS (an omp child's transcript
1400
+ included) - or a `job` past SLICE_JOB_SECONDS - closes `stale`. One stat and
1401
+ at most one SLICE_TAIL read per open omp record."""
1402
+ out = []
1403
+ for rec in slices(root):
1404
+ if rec.get("status") != "open":
1405
+ if rec.get("status") == "stopped":
1406
+ out.append(rec)
1407
+ continue
1408
+ idle, limit = _age(rec), SLICE_IDLE_SECONDS
1409
+ if rec.get("kind") == "job":
1410
+ limit = SLICE_JOB_SECONDS
1411
+ elif rec.get("host") == "omp":
1412
+ if not rec.get("transcript_dir") and idle is not None and idle < 600:
1413
+ # the parent's directory did not exist yet when the record
1414
+ # opened: one more glob while the record is young, then kept
1415
+ found = _omp_session_dir(rec.get("parent"))
1416
+ if found:
1417
+ rec["transcript_dir"] = found
1418
+ _write_record(root, rec)
1419
+ path = _omp_transcript(rec)
1420
+ end = _transcript_end(path) if path else None
1421
+ if end:
1422
+ rec = close_slice(root, rec["id"], *end) or rec
1423
+ elif path:
1424
+ try:
1425
+ import time
1426
+ idle = min(idle if idle is not None else 1e18,
1427
+ time.time() - os.stat(path).st_mtime)
1428
+ except OSError:
1429
+ pass
1430
+ if rec.get("status") == "open" and idle is not None and idle > limit:
1431
+ rec = close_slice(root, rec["id"], "stale") or rec
1432
+ if rec.get("status") in ("open", "stopped"):
1433
+ out.append(rec)
1434
+ return out
1435
+
1436
+
1437
+ def _cut(line, keep=""):
1438
+ """`line` + `keep` cut to SLICE_LINE characters, the cut taken from `line` so
1439
+ `keep` (the command that acts on the record) survives whole."""
1440
+ room = SLICE_LINE - len(keep)
1441
+ if len(line) > room:
1442
+ line = line[:max(0, room - 3)] + "..."
1443
+ return (line + keep)[:SLICE_LINE]
1444
+
1445
+
1446
+ def slice_line(rec):
1447
+ """One bounded line (SLICE_LINE characters) for one record."""
1448
+ sid = rec.get("id") or ""
1449
+ status = rec.get("status") or ""
1450
+ if rec.get("kind") == "job":
1451
+ return _cut("- job %s %s: %s (parent session %s) - read its result there "
1452
+ "before re-running" % (sid, status,
1453
+ " ".join(str(rec.get("brief") or "").split())[:60],
1454
+ rec.get("parent") or "?"))
1455
+ head = "- slice %s (%s/%s) %s%s attempts %s" % (
1456
+ sid, rec.get("host") or "?", rec.get("agent") or "?", status,
1457
+ (" " + rec["failure"]) if rec.get("failure") else "", rec.get("attempts") or 1)
1458
+ tree = rec.get("worktree") or ""
1459
+ if rec.get("repo"):
1460
+ head += " (in %s)" % os.path.basename(rec["repo"])
1461
+ if tree and os.path.isdir(tree):
1462
+ head += " worktree %s @ %s" % (tree, _git(tree, "rev-parse", "--short", "HEAD") or "?")
1463
+ return _cut(head, ": tezgah-task slice resume %s" % sid)
1464
+
1465
+
1466
+ def slice_lines(root, limit=3, session=None, others=False):
1467
+ """One line per pending record, newest first, at most `limit` of them; with
1468
+ `session`, only the records that session spawned (`parent`) or that no
1469
+ session owns (one opened by hand with no `--parent`) - another session's
1470
+ slices are its own to resume. With `others`, one closing line counts those
1471
+ others, so a new session after a crash or a host switch still learns they
1472
+ exist (the resume block asks for it, the per-prompt part does not)."""
1473
+ recs = pending(root)
1474
+ if session is None:
1475
+ return [slice_line(rec) for rec in recs[:limit]]
1476
+ mine = [r for r in recs if r.get("parent") in (session, "", None)]
1477
+ out = [slice_line(rec) for rec in mine[:limit]]
1478
+ if others and len(recs) > len(mine):
1479
+ out.append("- %d pending slice(s) of other sessions: tezgah-task slice reconcile"
1480
+ % (len(recs) - len(mine)))
1481
+ return out