@r1z4x/tezgah 1.6.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +140 -1
  2. package/MANIFEST +7 -0
  3. package/README.md +6 -3
  4. package/bin/tezgah-decisions +6 -14
  5. package/bin/tezgah-design +21 -5
  6. package/bin/tezgah-docs +18 -16
  7. package/bin/tezgah-doctor +152 -0
  8. package/bin/tezgah-lessons +4 -1
  9. package/bin/tezgah-research +249 -17
  10. package/bin/tezgah-route +70 -8
  11. package/bin/tezgah-setup +26 -13
  12. package/bin/tezgah-task +877 -60
  13. package/bin/tezgah-taste +56 -15
  14. package/docs/architecture.md +28 -11
  15. package/docs/clarity-baseline.json +2 -2
  16. package/docs/contract.md +108 -24
  17. package/docs/environment.md +3 -1
  18. package/docs/evidence.md +209 -28
  19. package/docs/feature-audit.md +1 -1
  20. package/docs/gate.md +78 -23
  21. package/docs/glossary.md +4 -4
  22. package/docs/hosts.md +8 -8
  23. package/docs/index.json +11 -5
  24. package/docs/judge.md +9 -3
  25. package/docs/models.md +75 -14
  26. package/docs/operations.md +133 -3
  27. package/docs/research.md +349 -15
  28. package/docs/skills.md +1 -1
  29. package/docs/status-line.md +5 -5
  30. package/docs/testing.md +20 -13
  31. package/docs/vision.md +12 -4
  32. package/hooks/hooks.json +2 -2
  33. package/hooks/lang/en.json +323 -0
  34. package/hooks/lang/tr.json +344 -0
  35. package/hooks/projects-auto-init.py +29 -1
  36. package/hooks/projects-posttooluse.py +24 -5
  37. package/hooks/projects-pretooluse.py +9 -4
  38. package/hooks/projects-stop.py +28 -16
  39. package/hooks/tezgah_agents.py +53 -17
  40. package/hooks/tezgah_clarity.py +7 -3
  41. package/hooks/tezgah_context.py +489 -438
  42. package/hooks/tezgah_gate.py +996 -80
  43. package/hooks/tezgah_integrity.py +1547 -197
  44. package/hooks/tezgah_models.py +427 -45
  45. package/hooks/tezgah_paths.py +147 -16
  46. package/hooks/tezgah_policy.py +261 -129
  47. package/hooks/tezgah_rank.py +29 -27
  48. package/hooks/tezgah_replay.py +200 -30
  49. package/hooks/tezgah_research.py +1303 -14
  50. package/hooks/tezgah_stopspec.py +7 -0
  51. package/hooks/tezgah_store.py +74 -27
  52. package/hooks/tezgah_task.py +1201 -40
  53. package/hooks/tezgah_taste.py +8 -4
  54. package/hosts/codex/hook.py +32 -13
  55. package/hosts/cursor/hook.py +32 -14
  56. package/hosts/dsh/hooks.json +2 -2
  57. package/hosts/omp/hook.py +44 -23
  58. package/hosts/omp/tezgah-hook.ts.in +93 -7
  59. package/hosts/opencode/plugins/tezgah.js +273 -6
  60. package/output-styles/tezgah.md +76 -42
  61. package/package.json +1 -1
  62. package/skills/i-have-adhd/EVALS.md +22 -2
  63. package/skills/i-have-adhd/SKILL.md +12 -5
  64. package/skills/plan-add/SKILL.md +27 -11
  65. package/skills/plan-add/render_table.py +8 -3
  66. package/skills/plan-sync/SKILL.md +5 -3
  67. package/skills/ponytail/EVALS.md +21 -2
  68. package/skills/ponytail/SKILL.md +4 -4
  69. package/skills/research/SKILL.md +89 -1
  70. package/skills/tezgah-contract/EVALS.md +52 -7
  71. package/skills/tezgah-contract/SKILL.md +154 -59
@@ -42,9 +42,17 @@ 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
55
+ import shlex
48
56
 
49
57
  TASK_PHASES = ("discovery", "implementation", "verification")
50
58
  WRITE_PHASES = ("implementation", "verification")
@@ -54,12 +62,15 @@ def repo_root(cwd, base):
54
62
  """The git top-level at or above cwd and under base, else the first path
55
63
  component below base. Walks up for a `.git` entry (dir or file: a worktree's
56
64
  is a file); no subprocess. Same answer as tezgah_context.repo_root without
57
- 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
58
68
  cwd = os.path.realpath(cwd)
59
69
  inside = os.path.realpath(base) if base else None
60
70
  cur = cwd
61
71
  while True:
62
72
  if os.path.exists(os.path.join(cur, ".git")):
73
+ cur = project_top(cur)
63
74
  if inside and _under(cur, inside):
64
75
  return cur
65
76
  break
@@ -157,6 +168,10 @@ _MARKER = re.compile(r"unverifiable(?![-\w])", re.I)
157
168
  # names the word ("either the command or the word `unverifiable` followed by
158
169
  # why") has no separator there and is not a declaration.
159
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.*)")
160
175
  _SPAN = re.compile(r"`([^`\n]+)`")
161
176
  # A runner whose test entry point is a bare subcommand: `pnpm test`, `cargo
162
177
  # test`, `uv run pytest` carry no option and no path, yet each is the command.
@@ -269,6 +284,14 @@ def acceptance_gap(text):
269
284
  return unproven(acceptance_items(text))
270
285
 
271
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
+
272
295
  # The `checkpoint:` values a plan can carry: a commit sha (the tree was clean at
273
296
  # the phase move, so the boundary is already real), or `pending <sha>` while the
274
297
  # tree still holds work no commit names - `<sha>` being the `HEAD` the commit the
@@ -307,13 +330,15 @@ def slug(path):
307
330
  return name[:-3] if name.endswith(".md") else name
308
331
 
309
332
 
310
- def checkpoint_command(path):
333
+ def checkpoint_command(path, worktree=None):
311
334
  """The one command that makes a plan's boundary real: a commit whose message
312
335
  marks where the risky work starts. Named by the CLI's note and by the gate's
313
336
  refusal - the same string, from the same caller-visible place - and running it
314
337
  is also what clears the refusal: it moves HEAD off the sha the record wrote
315
- (`checkpoint_sha`)."""
316
- 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))
317
342
 
318
343
 
319
344
  def spike_unanswered(text):
@@ -445,20 +470,53 @@ def parallel(rows):
445
470
  return picked if len(picked) > 1 else []
446
471
 
447
472
 
448
- def active(cwd, base):
449
- """The active task as a dict, or None. Scans `<repo_root>/.tezgah/plans/open/*.md`
450
- sorted by name, first file whose frontmatter carries a valid `phase`.
451
- Dict: {"id","title","phase","allowed_paths","checkpoint","path"}: id/title are
452
- the frontmatter values (id falls back to the filename's NNN), checkpoint is
453
- 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.
454
491
  Never raises: an unreadable directory or file means None (readers fail
455
492
  open)."""
456
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."""
457
514
  directory = os.path.join(root, ".tezgah", "plans", "open")
458
515
  try:
459
516
  names = sorted(os.listdir(directory))
460
517
  except OSError:
461
- return None
518
+ return []
519
+ out = []
462
520
  for name in names:
463
521
  if not name.endswith(".md"):
464
522
  continue
@@ -467,29 +525,158 @@ def active(cwd, base):
467
525
  with open(path, "r", encoding="utf-8") as fh:
468
526
  text = fh.read()
469
527
  except OSError:
470
- return None
528
+ return []
471
529
  fields = frontmatter(text)
472
530
  if fields.get("phase") not in TASK_PHASES:
473
531
  continue
474
- # A plan the repository shipped is data, not the user's task: its phase
475
- # and allowlist would lock writes in a cloned repo (audit L-16). Only
476
- # positive evidence turns the rule off - the index tracks `.tezgah` -
477
- # never a can't-tell, which would switch the user's own guard off in
478
- # silence (review R2); the answer is cached on the index's stat.
479
- from tezgah_paths import workspace_tracked
480
- if workspace_tracked(root):
481
- return None
482
- return {"id": fields.get("id") or name.split("-")[0],
483
- "title": fields.get("title", ""),
484
- "phase": fields["phase"],
485
- "allowed_paths": allowed_paths(text),
486
- # read from the frontmatter already in hand, so the gate's
487
- # checkpoint rule costs this module no second read of the plan
488
- "checkpoint": fields.get("checkpoint"),
489
- "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
490
634
  return None
491
635
 
492
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
+
493
680
  def match(rel, pattern):
494
681
  """Glob match for one allowlist pattern against a repo-relative posix path.
495
682
  `**` crosses separators, `*` does not, everything else is literal."""
@@ -513,29 +700,121 @@ def match(rel, pattern):
513
700
  return re.fullmatch("".join(out), rel) is not None
514
701
 
515
702
 
516
- def relative(path, cwd, base):
703
+ def relative(path, cwd, base, worktrees=False):
517
704
  """The realpath of `path` (absolute or relative to cwd) as a posix path
518
705
  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)."""
706
+ (so an outside write can never be inside the allowlist).
707
+
708
+ With `worktrees`, a linked worktree of the same repository is that
709
+ repository, not outside it: the contract gives every writing slice its own
710
+ worktree, and an allowlist that could not name `hooks/a.py` there refused the
711
+ workflow it prescribes (three slices of plan 077 hit it). A path under such a
712
+ worktree answers relative to the worktree's top. Only the allowlist asks for
713
+ this (`hooks/tezgah_gate.py::task_reason`); the record and workspace rules
714
+ keep the main-root reading, so their decisions do not move. The test is
715
+ fork-free and only a path already outside the root pays for it; a worktree of
716
+ another repository is still outside."""
520
717
  root = repo_root(cwd, base)
718
+ path = os.path.expanduser(path) # a host may spell it `~/...`
521
719
  real = os.path.realpath(path if os.path.isabs(path) else os.path.join(cwd, path))
522
720
  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
721
+ top = _sibling_worktree(real, root) if worktrees else None
722
+ if top is None or real == top:
723
+ # the root itself is not a path inside the root: an allowlist is
724
+ # about files under it, so this is a None like any other, and every
725
+ # caller fails in the refusing direction
726
+ return None
727
+ root = top
527
728
  return real[len(root) + 1:].replace(os.sep, "/")
528
729
 
529
730
 
731
+ def _sibling_worktree(real, root):
732
+ """The top of the linked worktree holding `real` when it is a checkout of the
733
+ repository `root` is a checkout of, else None.
734
+
735
+ The worktree's admin directory has to point back at it (`<admin>/gitdir` is
736
+ `<top>/.git`, which git always writes): a `.git` file planted anywhere that
737
+ names a real admin directory would otherwise make any directory a sibling."""
738
+ from tezgah_paths import linked_main, worktree_top
739
+ top = worktree_top(real)
740
+ if top is None:
741
+ return None
742
+ main = linked_main(root) or root
743
+ if linked_main(top) != os.path.realpath(main):
744
+ return None
745
+ try:
746
+ with open(os.path.join(top, ".git"), encoding="utf-8") as fh:
747
+ admin = fh.readline().strip()[len("gitdir:"):].strip()
748
+ admin = os.path.normpath(os.path.join(top, admin))
749
+ with open(os.path.join(admin, "gitdir"), encoding="utf-8") as fh:
750
+ back = fh.readline().strip()
751
+ except (OSError, UnicodeDecodeError):
752
+ return None
753
+ back = os.path.normpath(os.path.join(admin, back))
754
+ return top if os.path.realpath(back) == os.path.join(top, ".git") else None
755
+
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
+
530
804
  def set_fields(path, **fields):
531
- """Rewrite these frontmatter keys in the plan file in place: replace the
805
+ """Rewrite these frontmatter keys in the plan file: replace the
532
806
  `key: value` line when present, insert it before the closing `---` when not.
533
807
  `allowed_paths` takes a list and is written as a `- item` block, replacing an
534
808
  existing block. `updated` is not special-cased here: the CLI passes today's
535
809
  date under it, as it does for a research line's `created:`, so this module
536
- needs no clock of its own. Raises OSError."""
537
- with open(path, "r", encoding="utf-8") as fh:
538
- 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)
539
818
  if not lines or lines[0].strip() != "---":
540
819
  lines = ["---", "---"] + lines
541
820
  close = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
@@ -556,8 +835,17 @@ def set_fields(path, **fields):
556
835
  end += 1
557
836
  block[head:end] = fresh
558
837
  lines[1:close] = block
559
- with open(path, "w", encoding="utf-8") as fh:
560
- 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)
561
849
 
562
850
 
563
851
  def _field_lines(key, value):
@@ -570,3 +858,876 @@ def _field_lines(key, value):
570
858
  if value is None or value == "":
571
859
  return [key + ":"]
572
860
  return ["%s: %s" % (key, value)]
861
+
862
+
863
+ def _lines(path):
864
+ with open(path, "r", encoding="utf-8") as fh:
865
+ return fh.read().split("\n")
866
+
867
+
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]
884
+
885
+
886
+ def set_section(path, heading, body):
887
+ """Replace the body of the last `## <heading>` section - the newest, since
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
903
+
904
+
905
+ # ---------------------------------------------------------------- slice journal
906
+ #
907
+ # One row per delegated slice in `<main checkout>/.tezgah/slices/slices.db`,
908
+ # so a parent crash, an omp `/clear`, a quota stop or a model or host switch
909
+ # loses no slice: the record names the brief, the worktree and branch it works
910
+ # in, and how it ended. Written by the post-tool path on a spawn call
911
+ # (`note_spawn`) and by `tezgah-task slice`; read by the context line
912
+ # (`slice_lines`), the Stop rule and `tezgah-task slice reconcile|resume|stats`.
913
+ # Every reader fails open: an unreadable record is skipped, never a refusal.
914
+ # These functions shell out to git; none of them is on the gate's write path.
915
+
916
+ SLICE_KINDS = ("agent", "job")
917
+ SLICE_HOSTS = ("omp", "claude", "codex", "cursor", "opencode", "dsh")
918
+ # `stale`: no terminal status was observed within SLICE_IDLE_SECONDS - a host
919
+ # that reports none (Codex `spawn_agent` returns at once and tezgah sees no child
920
+ # event) or a child that died without writing its end. It leaves the pending
921
+ # list without being read as finished or as failed.
922
+ SLICE_STATUSES = ("open", "done", "stopped", "resumed", "stale")
923
+ SLICE_CLOSED = ("done", "stopped", "resumed", "stale")
924
+ SLICE_IDLE_SECONDS = 6 * 3600
925
+ FAILURES = ("quota", "rate_limit", "aborted", "timeout", "cut_off", "error")
926
+ SLICE_BRIEF = 4000
927
+ SLICE_LINE = 160
928
+ SLICE_TAIL = 64 * 1024
929
+ _SLICE_ID = re.compile(r"[^A-Za-z0-9._-]+")
930
+ # Failure classes, first match wins, in this order. The table moved here from
931
+ # the research line's measure (data/E2-subagent-stops/measure.py::CLASSES) and
932
+ # was narrowed to the classes a lever exists for (`tezgah-task slice stats`).
933
+ _CLASSES = (
934
+ ("rate_limit", re.compile(r"\b429\b|rate[ _-]?limit", re.I)),
935
+ ("quota", re.compile(r"quota|key limit exceeded|usage limit|"
936
+ r"\b40[23]\b.*\blimit|\blimit\b.*\b40[23]\b", re.I)),
937
+ ("aborted", re.compile(r"abort|cancell?ed", re.I)),
938
+ ("timeout", re.compile(r"timed out|timeout", re.I)),
939
+ ("cut_off", re.compile(r"max[ _]turns|max_tokens|cut off|context[ _]length",
940
+ re.I)),
941
+ )
942
+
943
+
944
+ def _now():
945
+ import time
946
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
947
+
948
+
949
+ def _git(cwd, *args):
950
+ """One git command's stdout in `cwd`, or None when git cannot answer."""
951
+ import subprocess
952
+ try:
953
+ out = subprocess.run(["git", "-C", cwd] + list(args), capture_output=True,
954
+ text=True, timeout=10)
955
+ except (OSError, subprocess.SubprocessError):
956
+ return None
957
+ return out.stdout.strip() if out.returncode == 0 else None
958
+
959
+
960
+ def _journal_root(root):
961
+ """The main checkout of `root`'s repository: every worktree of one repository
962
+ writes one journal, so a slice opened from a worktree is still found from the
963
+ main checkout the parent session runs in."""
964
+ from tezgah_paths import linked_main
965
+ real = os.path.realpath(root)
966
+ return linked_main(real) or real
967
+
968
+
969
+ def slice_dir(root):
970
+ """`<main checkout>/.tezgah/slices`, the one directory the records live in."""
971
+ return os.path.join(_journal_root(root), ".tezgah", "slices")
972
+
973
+
974
+ def _digest(text):
975
+ import hashlib
976
+ return hashlib.sha1(text.encode("utf-8", "replace")).hexdigest()[:12]
977
+
978
+
979
+ def slice_id(name, brief=""):
980
+ """A record id: `name` cut to [A-Za-z0-9._-] and 80 characters, else the
981
+ first 12 hex digits of the brief's sha1 - so a re-dispatch of the same brief
982
+ re-opens the same record and counts an attempt."""
983
+ sid = _SLICE_ID.sub("-", str(name or "")).strip("-.")[:80]
984
+ return sid or _digest(brief)
985
+
986
+
987
+ # The journal is one SQLite file opened like the taste store
988
+ # (`tezgah_store.connect`: WAL, a busy timeout, 0600). One JSON file per record
989
+ # piled up a file per subagent in every repository (owner, 2026-10-11). `row`
990
+ # is the record as JSON; `parent`, `status` and `updated` are the columns a
991
+ # reader filters and sorts on, and `seq` orders two writes in one second.
992
+ SLICE_DB = "slices.db"
993
+ SLICE_SCHEMA = """
994
+ CREATE TABLE IF NOT EXISTS slices (id TEXT PRIMARY KEY, parent TEXT NOT NULL,
995
+ status TEXT NOT NULL, updated TEXT NOT NULL,
996
+ seq INTEGER NOT NULL, row TEXT NOT NULL);
997
+ """
998
+
999
+
1000
+ @contextlib.contextmanager
1001
+ def _journal(root, create=True):
1002
+ """The journal database of `root`'s repository, the `*.json` records an
1003
+ older release wrote imported first (`_import_json`). With `create` False it
1004
+ yields None where neither the database nor an old record exists, so a read
1005
+ leaves no file behind. The directory carries a `.gitignore` of `*`: the
1006
+ records are runtime state, never part of the private `.tezgah` history."""
1007
+ import glob
1008
+ import tezgah_store
1009
+ folder = slice_dir(root)
1010
+ path = os.path.join(folder, SLICE_DB)
1011
+ if not create and not os.path.exists(path) and not glob.glob(
1012
+ os.path.join(folder, "*.json")):
1013
+ yield None
1014
+ return
1015
+ os.makedirs(folder, mode=0o700, exist_ok=True)
1016
+ ignore = os.path.join(folder, ".gitignore")
1017
+ if not os.path.exists(ignore):
1018
+ with open(ignore, "w", encoding="utf-8") as fh:
1019
+ fh.write("*\n")
1020
+ conn = tezgah_store.connect(path, SLICE_SCHEMA, version=1)
1021
+ try:
1022
+ _import_json(conn, folder)
1023
+ yield conn
1024
+ finally:
1025
+ conn.close()
1026
+
1027
+
1028
+ def _import_json(conn, folder):
1029
+ """Bring the one-file-per-record journal into the table, then delete each
1030
+ file it took. A file whose `updated` is newer than the row replaces it - an
1031
+ older release still running writes files beside the database - and an
1032
+ older one leaves the row alone. A file that does not parse, or whose id is
1033
+ not a string, stays where it is. With no old file, one glob."""
1034
+ import glob
1035
+ import json
1036
+ for path in glob.glob(os.path.join(folder, "*.json")):
1037
+ try:
1038
+ with open(path, encoding="utf-8") as fh:
1039
+ rec = json.load(fh)
1040
+ except (OSError, ValueError):
1041
+ continue
1042
+ if not isinstance(rec, dict) or not isinstance(rec.get("id"), str) \
1043
+ or not rec["id"]:
1044
+ continue
1045
+ held = conn.execute("SELECT updated FROM slices WHERE id = ?",
1046
+ (rec["id"],)).fetchone()
1047
+ if held is None or str(rec.get("updated") or "") > held[0]:
1048
+ _put(conn, rec)
1049
+ try:
1050
+ os.remove(path)
1051
+ except OSError:
1052
+ pass
1053
+
1054
+
1055
+ def _put(conn, rec):
1056
+ """Upsert one record."""
1057
+ import json
1058
+ conn.execute(
1059
+ "INSERT OR REPLACE INTO slices (id, parent, status, updated, seq, row) "
1060
+ "VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(seq), 0) + 1 FROM slices), ?)",
1061
+ (rec["id"], str(rec.get("parent") or ""), str(rec.get("status") or ""),
1062
+ str(rec.get("updated") or ""), json.dumps(rec, sort_keys=True)))
1063
+
1064
+
1065
+ def _row(text):
1066
+ import json
1067
+ try:
1068
+ rec = json.loads(text)
1069
+ except ValueError:
1070
+ return None
1071
+ return rec if isinstance(rec, dict) and rec.get("id") else None
1072
+
1073
+
1074
+ def _read_record(root, sid):
1075
+ """The record `sid`, or None; a journal that cannot be opened reads as none."""
1076
+ import tezgah_store
1077
+ try:
1078
+ with _journal(root, create=False) as db:
1079
+ hit = db and db.execute("SELECT row FROM slices WHERE id = ?",
1080
+ (slice_id(sid),)).fetchone()
1081
+ except tezgah_store.ERRORS:
1082
+ return None
1083
+ return _row(hit[0]) if hit else None
1084
+
1085
+
1086
+ def _write_record(root, rec):
1087
+ """Write one record (one upsert statement) and return it. A database that
1088
+ cannot be opened or written raises OSError, the error every caller already
1089
+ handles (`tezgah-task slice` prints it, the hooks swallow it)."""
1090
+ import tezgah_store
1091
+ try:
1092
+ with _journal(root) as db:
1093
+ _put(db, rec)
1094
+ except tezgah_store.ERRORS as exc:
1095
+ raise OSError("slice journal %s: %s" % (slice_dir(root), exc)) from exc
1096
+ return rec
1097
+
1098
+
1099
+ def _own_id(root, base, brief, parent):
1100
+ """The record id a spawn of `base` opens: `base` itself, unless another
1101
+ session holds that record under another brief. Then it is this session's
1102
+ own record of that name, else `base-<digest6>` of the brief, so another
1103
+ session's stopped slice stays its owner's (research orchestrator-flow-audit
1104
+ S7). A same-brief re-dispatch from another session still re-opens `base`."""
1105
+ held = _read_record(root, base)
1106
+ if (held is None or not parent or not brief
1107
+ or held.get("parent") in ("", None, parent)
1108
+ or held.get("brief") == brief):
1109
+ return base
1110
+ own = next((r["id"] for r in slices(root) if r.get("parent") == parent
1111
+ and (r.get("name") or r["id"]) == base), None)
1112
+ return own or "%s-%s" % (base, _digest(brief)[:6])
1113
+
1114
+
1115
+ def find_slice(root, ref, session=None):
1116
+ """The record `ref` names for `tezgah-task slice done|stop|resume`. An
1117
+ exact id answers whatever session owns it: an id is unambiguous, and it is
1118
+ what `slice reconcile` and the review guard print, so a crashed session's
1119
+ slice stays reachable. A bare spawned `name` is a lookup: with `session`
1120
+ (TEZGAH_SESSION), the newest record of that name the session spawned or no
1121
+ session owns; without one, the one record of that name. None when nothing
1122
+ answers."""
1123
+ ref = slice_id(ref)
1124
+ recs = slices(root)
1125
+ exact = next((r for r in recs if r["id"] == ref), None)
1126
+ if exact or session:
1127
+ return exact or next((r for r in recs if r.get("name") == ref
1128
+ and r.get("parent") in (session, "", None)), None)
1129
+ named = [r for r in recs if r.get("name") == ref]
1130
+ return named[0] if len(named) == 1 else None
1131
+
1132
+
1133
+ def _worktree_in(root, *texts):
1134
+ """The linked worktree of this repository the first of `texts` that names
1135
+ one names, else "": a slice's own task before the batch's shared context
1136
+ (which may list every slice's worktree), the one named last in a text, and
1137
+ on a tie the longer path, since `.../a` is found inside `.../a-b` at the
1138
+ same place. What reconcile, resume, the board and merge-plan read."""
1139
+ from tezgah_paths import worktrees
1140
+ trees = worktrees(root)[1:]
1141
+ for text in texts:
1142
+ found = [p for p in trees if text and p in text]
1143
+ if found:
1144
+ return max(found, key=lambda p: (text.rfind(p), len(p)))
1145
+ return ""
1146
+
1147
+
1148
+ def _named_worktree(root, *texts):
1149
+ """(worktree, branch) for the linked worktree `_worktree_in` reads off
1150
+ `texts`, else ("", "")."""
1151
+ top = _worktree_in(root, *texts)
1152
+ return (top, _git(top, "symbolic-ref", "-q", "--short", "HEAD") or "") if top else ("", "")
1153
+
1154
+
1155
+ # Where a brief says its slice works: a `Worktree:`, `Checkout` or `Cwd:` line
1156
+ # opening with an absolute path. A path merely mentioned - a worktree to remove,
1157
+ # a sibling's tree, a file to read - is no declaration (review of S6, F2). Only
1158
+ # the main-checkout refusal reads it (`tezgah_gate.lease_reason`).
1159
+ _DECLARED = re.compile(r"(?im)^[ \t>#*_-]*(?:worktree|checkout|cwd)\b[*_]*[ \t]*:?"
1160
+ r"[*_]*[ \t]*`?(/[^\s`'\"()<>,;]+)")
1161
+
1162
+
1163
+ def _declared_in(root, *texts):
1164
+ """The linked worktree of this repository the first of `texts` that
1165
+ declares a checkout of it declares, else "": a slice's own task before the
1166
+ batch's shared context, the last declaration in a text. A declared main
1167
+ checkout is none - an integrator works there."""
1168
+ from tezgah_paths import worktrees
1169
+ trees = worktrees(root)
1170
+ for text in texts:
1171
+ found = [os.path.realpath(p.rstrip("/.:")) for p in _DECLARED.findall(text or "")]
1172
+ found = [p for p in found if p in trees]
1173
+ if found:
1174
+ return found[-1] if found[-1] != trees[0] else ""
1175
+ return ""
1176
+
1177
+
1178
+ # `Owns:` in a slice's own task: the repository-relative globs only that slice
1179
+ # writes while its record is open (research orchestrator-flow-audit S6, pick
1180
+ # V2). The gate refuses a sibling's write into them (`tezgah_gate.lease_reason`).
1181
+ # The globs follow on the line, split on commas and spaces, or - with nothing
1182
+ # after the colon - as the `- glob` list items under it. Markdown emphasis and
1183
+ # backticks are dropped; an absolute glob under the slice's checkout is cut to it.
1184
+ _OWNS = re.compile(r"(?i)^[ \t>#*_-]*owns[*_]*[ \t]*:[*_]*[ \t]*(.*)$")
1185
+ _LIST_ITEM = re.compile(r"^[ \t>]*(?:[-*+]|\d+[.)])[ \t]+(.+)$")
1186
+
1187
+
1188
+ def owned_globs(text, tops=()):
1189
+ """The `Owns:` globs `text` names, in order, each once."""
1190
+ found, lines = [], (text or "").splitlines()
1191
+ for i, line in enumerate(lines):
1192
+ hit = _OWNS.match(line)
1193
+ if not hit:
1194
+ continue
1195
+ if hit.group(1).strip():
1196
+ found.append(hit.group(1))
1197
+ continue
1198
+ for item in lines[i + 1:]:
1199
+ listed = _LIST_ITEM.match(item)
1200
+ if not listed:
1201
+ break
1202
+ found.append(listed.group(1))
1203
+ out = []
1204
+ for line in found:
1205
+ for glob in re.split(r"[\s,]+", line.replace("`", "")):
1206
+ glob = glob.rstrip(".;")
1207
+ top = next((t for t in tops if t and glob.startswith(t + "/")), "")
1208
+ glob = glob[len(top) + 1:] if top else glob
1209
+ if glob and glob not in out:
1210
+ out.append(glob)
1211
+ return out
1212
+
1213
+
1214
+ def slice_of(root, parent, name):
1215
+ """The newest record `parent` spawned under `name` - a subagent's own
1216
+ record, read from its `spawned` row (`tezgah_integrity.spawn_of`), or from
1217
+ Claude's `agent_id`, which a background spawn stores as `child` - else
1218
+ None. An exact name or child first; omp names a re-dispatch `<name>-2`, so
1219
+ the bare name answers only after that."""
1220
+ if not (parent and name):
1221
+ return None
1222
+ mine = [r for r in slices(root) if r.get("parent") == parent]
1223
+ base = re.sub(r"-\d+$", "", name)
1224
+ return (next((r for r in mine if (r.get("name") or r["id"]) == name
1225
+ or r.get("child") == name), None)
1226
+ or next((r for r in mine if (r.get("name") or r["id"]) == base), None))
1227
+
1228
+
1229
+ def owner_of(root, path, parent, me):
1230
+ """The open record of another writing slice `parent` spawned whose `owns`
1231
+ matches the repository-relative `path`, else None; `me` is the writing
1232
+ slice's id. A reader - an agent named for a read-only role - holds no
1233
+ lease, whatever `Owns:` line its task quotes. A hit re-reads after
1234
+ `pending`, which closes an omp sibling whose child transcript ended, so a
1235
+ finished owner frees its files."""
1236
+ def held():
1237
+ return next((r for r in slices(root) if parent and r.get("parent") == parent
1238
+ and r.get("status") == "open" and r["id"] != me
1239
+ and not _READ_ONLY_AGENT.search(r.get("agent") or "")
1240
+ and any(match(path, g) for g in r.get("owns") or ())), None)
1241
+ if held() is None:
1242
+ return None
1243
+ pending(root)
1244
+ return held()
1245
+
1246
+
1247
+ def close_child(root, parent, child):
1248
+ """Close as done the open record `parent` spawned whose `child` is the
1249
+ host's id of a background child that just ended (Claude's SubagentStop),
1250
+ and return it; None when no record holds it."""
1251
+ rec = next((r for r in slices(root) if parent and child and r.get("parent") == parent
1252
+ and r.get("child") == child and r.get("status") == "open"), None)
1253
+ return close_slice(root, rec["id"], "done") if rec else None
1254
+
1255
+
1256
+ BOARD_LINES = 12
1257
+
1258
+
1259
+ def board(root, parent=None, limit=BOARD_LINES):
1260
+ """One bounded line (SLICE_LINE) per open record `parent` spawned - every
1261
+ open record without one - newest first, at most `limit` and a count of the
1262
+ rest: what each slice owns, where it works and its last commit. A pull
1263
+ read: siblings see each other's leases here instead of by message."""
1264
+ recs = [r for r in pending(root) if r.get("status") == "open"
1265
+ and r.get("kind") != "job" and (parent is None or r.get("parent") == parent)]
1266
+ out = []
1267
+ for rec in recs[:limit]:
1268
+ tree = rec.get("worktree") or ""
1269
+ head = _git(tree, "log", "-1", "--format=%h") if tree and os.path.isdir(tree) else None
1270
+ out.append(_cut("- %s (%s/%s) owns %s; worktree %s on %s @ %s" % (
1271
+ rec.get("name") or rec["id"], rec.get("host") or "?", rec.get("agent") or "?",
1272
+ ", ".join(rec.get("owns") or ()) or "nothing declared", tree or "none",
1273
+ rec.get("branch") or "?", head or "?")))
1274
+ if len(recs) > limit:
1275
+ out.append("- %d more open slice(s)" % (len(recs) - limit))
1276
+ return out
1277
+
1278
+
1279
+ # An agent whose name reads as a read-only role shares a worktree freely: a
1280
+ # reviewer panel on one slice's checkout is the allowed case (research
1281
+ # orchestrator-flow-audit S6, finding 5). A brief with `Owns:` writes whatever
1282
+ # its agent is called.
1283
+ _READ_ONLY_AGENT = re.compile(r"review|explor|research|scout|audit|critic|judge", re.I)
1284
+ SHARED_WORKTREE = (
1285
+ "tezgah: writing slices %s all name worktree %s. Every writing slice gets its own "
1286
+ "branch and checkout (`orca worktree create` inside Orca, else `git worktree "
1287
+ "add`): two slices in one checkout overwrite each other's edits.")
1288
+
1289
+
1290
+ def _writing(agent, owns):
1291
+ return bool(owns) or not _READ_ONLY_AGENT.search(agent or "")
1292
+
1293
+
1294
+ def _writes(rec):
1295
+ """A writing record: its own task's `Owns:`, else an agent not named for a
1296
+ read-only role. A reviewer whose lease came from a shared brief is not one."""
1297
+ return _writing(rec.get("agent"), owned_globs(rec.get("task") or ""))
1298
+
1299
+
1300
+ def worktree_clash(root, tool, inp, parent=""):
1301
+ """SHARED_WORKTREE when two writing slices - of this spawn call, or this
1302
+ call and an open record `parent` spawned - name one worktree, else None.
1303
+ Each task's own text decides its worktree; one that names none takes the
1304
+ shared context's, which is how ten slices landed in one checkout."""
1305
+ main = _journal_root(root)
1306
+ seen = {}
1307
+ for rec in slices(root):
1308
+ if parent and rec.get("parent") == parent and rec.get("status") == "open" \
1309
+ and rec.get("worktree") and _writes(rec):
1310
+ seen.setdefault(rec["worktree"], set()).add(rec.get("name") or rec["id"])
1311
+ context = str(inp.get("context") or "").strip() if isinstance(inp, dict) else ""
1312
+ for sid, agent, brief in spawned(tool, inp):
1313
+ task = brief[len(context):] if context else brief
1314
+ tree = _worktree_in(main, task, brief)
1315
+ if tree and _writing(agent, owned_globs(task)):
1316
+ seen.setdefault(tree, set()).add(sid)
1317
+ clash = sorted((t, sorted(n)) for t, n in seen.items() if len(n) > 1)
1318
+ return SHARED_WORKTREE % (", ".join(clash[0][1]), clash[0][0]) if clash else None
1319
+
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
+
1325
+ def open_slice(root, sid, *, kind="agent", host="", agent="", brief="", parent="",
1326
+ cwd="", repo="", task="", child=""):
1327
+ """Open (or re-open) the record `sid` and return it. The worktree and branch
1328
+ are the ones the slice's own task names (else the brief, `_worktree_in`);
1329
+ `declared` is the worktree a `Worktree:`/`Checkout`/`Cwd:` line declares
1330
+ (`_declared_in`); the base is the main checkout's HEAD, and `owns` holds the
1331
+ `Owns:` globs of the task (else the brief, `owned_globs`). A re-open
1332
+ of an existing id counts one more attempt, sets the status back to open and
1333
+ clears the failure; a field the new call leaves empty keeps its old value.
1334
+ A record another session holds under another brief is not re-opened: the
1335
+ spawn gets its own id (`_own_id`), and `name` keeps the spawned name.
1336
+ `repo` names the repository the slice works in when that is not this
1337
+ journal's (`_brief_repo`); `child` is the host's id for the running child
1338
+ (Claude's background `agentId`), the key its own writes carry."""
1339
+ base = slice_id(sid, brief)
1340
+ main = _journal_root(root)
1341
+ own = task or brief or ""
1342
+ brief = (brief or "")[:SLICE_BRIEF]
1343
+ now = _now()
1344
+ sid = _own_id(root, base, brief, parent)
1345
+ rec = _read_record(root, sid)
1346
+ if rec is None:
1347
+ rec = {"id": sid, "name": base, "kind": kind if kind in SLICE_KINDS else "agent",
1348
+ "host": host, "agent": agent, "brief": brief, "parent": parent,
1349
+ "cwd": cwd, "repo": repo, "worktree": "", "branch": "", "attempts": 1,
1350
+ "opened": now, "child": child}
1351
+ else:
1352
+ rec["attempts"] = int(rec.get("attempts") or 0) + 1
1353
+ if parent and parent != rec.get("parent"):
1354
+ # a re-dispatch from another session (after a /clear, a crash, a
1355
+ # resume) writes its child under that session's directory: the old
1356
+ # one holds the first run, whose end must not close this record
1357
+ rec["transcript_dir"] = ""
1358
+ for key, value in (("host", host), ("agent", agent), ("brief", brief),
1359
+ ("parent", parent), ("cwd", cwd), ("repo", repo),
1360
+ ("child", child)):
1361
+ if value:
1362
+ rec[key] = value
1363
+ if task: # the slice's own task, without a batch's shared context
1364
+ rec["task"] = task[:SLICE_BRIEF]
1365
+ if brief or not rec.get("worktree"):
1366
+ rec["worktree"], rec["branch"] = _named_worktree(main, task, rec.get("brief"))
1367
+ rec["declared"] = _declared_in(main, task, rec.get("brief"))
1368
+ if own:
1369
+ rec["owns"] = owned_globs(own, (rec["worktree"], main))
1370
+ if rec.get("host") == "omp" and rec.get("parent") and not rec.get("transcript_dir"):
1371
+ # one glob over every omp project at open, so `pending` - run on every
1372
+ # prompt and Stop - never globs (40 ms on 1393 project dirs, measured)
1373
+ rec["transcript_dir"] = _omp_session_dir(rec["parent"]) 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 "")
1381
+ rec.update(base=_git(main, "rev-parse", "HEAD") or "", status="open",
1382
+ failure="", updated=now)
1383
+ return _write_record(root, rec)
1384
+
1385
+
1386
+ def close_slice(root, sid, status, failure="", stop=None):
1387
+ """Close the record `sid` as done, stopped or resumed; None when it has none.
1388
+ A stopped record takes `failure` (a FAILURES class or ""); done and resumed
1389
+ keep the class the slice stopped with, so the stats still count the event.
1390
+ A quota or rate-limit stop whose message `stop` ({errorMessage, provider,
1391
+ model, default}) names its family holds that family down
1392
+ (`tezgah_models.note_limit`; a rate limit briefly);
1393
+ that push fails open."""
1394
+ if status not in SLICE_CLOSED:
1395
+ raise ValueError("status %r is not one of %s" % (status, ", ".join(SLICE_CLOSED)))
1396
+ rec = _read_record(root, sid)
1397
+ if rec is None:
1398
+ return None
1399
+ if status == "stopped" or failure:
1400
+ rec["failure"] = failure if failure in FAILURES else ""
1401
+ rec.update(status=status, updated=_now())
1402
+ if failure in ("quota", "rate_limit") and isinstance(stop, dict):
1403
+ try:
1404
+ import tezgah_models
1405
+ tezgah_models.note_limit(
1406
+ stop.get("errorMessage") or "", stop.get("provider") or "",
1407
+ stop.get("model") or "", transient=failure == "rate_limit",
1408
+ default=stop.get("default") or "")
1409
+ except Exception: # noqa: BLE001 - health is advice: the close still lands
1410
+ pass
1411
+ return _write_record(root, rec)
1412
+
1413
+
1414
+ def slices(root):
1415
+ """Every readable record, newest `updated` first (the later write first on a
1416
+ tie); a row that does not parse, or a journal that cannot be opened, is
1417
+ skipped - readers fail open."""
1418
+ import tezgah_store
1419
+ try:
1420
+ with _journal(root, create=False) as db:
1421
+ rows = db.execute("SELECT row FROM slices ORDER BY updated DESC, "
1422
+ "seq DESC").fetchall() if db else []
1423
+ except tezgah_store.ERRORS:
1424
+ return []
1425
+ return [rec for rec in (_row(r[0]) for r in rows) if rec]
1426
+
1427
+
1428
+ def classify(text):
1429
+ """The failure class of an error or result text: rate_limit, quota, aborted,
1430
+ timeout, cut_off, else error for any other non-empty text, "" for none."""
1431
+ text = str(text or "").strip()
1432
+ if not text:
1433
+ return ""
1434
+ for name, pattern in _CLASSES:
1435
+ if pattern.search(text):
1436
+ return name
1437
+ return "error"
1438
+
1439
+
1440
+ def _brief(*parts):
1441
+ return "\n\n".join(str(p).strip() for p in parts if str(p or "").strip())
1442
+
1443
+
1444
+ def spawned(tool, inp):
1445
+ """[(id, agent, brief)] for one spawn call's input, [] for any other tool.
1446
+ omp `task` carries a batch ({tasks: [{name, agent, task}], context}, brief =
1447
+ context + task); Claude, dsh and Cursor `Agent`/`Task` and opencode `task`
1448
+ carry one ({description, prompt, subagent_type}); Codex `spawn_agent` carries
1449
+ {task_name, message|prompt, agent_type|agent} - its `task_name` is the id the
1450
+ user addresses the child by."""
1451
+ if not isinstance(inp, dict):
1452
+ return []
1453
+ name = str(tool or "").lower()
1454
+ out = []
1455
+ if name == "task" and isinstance(inp.get("tasks"), list):
1456
+ context = inp.get("context") or ""
1457
+ for item in inp["tasks"]:
1458
+ if not isinstance(item, dict):
1459
+ continue
1460
+ brief = _brief(context, item.get("task") or "")
1461
+ out.append((slice_id(item.get("name"), brief), str(item.get("agent") or ""),
1462
+ brief))
1463
+ elif name in ("task", "agent"):
1464
+ brief = _brief(inp.get("description") or "", inp.get("prompt") or "")
1465
+ out.append((slice_id(inp.get("name"), brief),
1466
+ str(inp.get("subagent_type") or ""), brief))
1467
+ elif name == "spawn_agent":
1468
+ brief = str(inp.get("message") or inp.get("prompt") or "")
1469
+ out.append((slice_id(inp.get("task_name") or inp.get("name"), brief),
1470
+ str(inp.get("agent_type") or inp.get("agent") or ""), brief))
1471
+ return [row for row in out if row[2]]
1472
+
1473
+
1474
+ def note_spawn(cwd, session_id, host, tool, inp, failed=None, result_text="",
1475
+ child=""):
1476
+ """Open one record per slice a spawn call started, and return them. A host
1477
+ that runs the subagent synchronously (Claude's PostToolUse on Agent/Task)
1478
+ passes `failed` as a bool and the record closes at once: stopped with the
1479
+ class of `result_text` (error when it names none), else done. No-op ([])
1480
+ under `slices-off` or outside a git checkout. The record stays in cwd's
1481
+ journal, the one the owner's Stop rule, resume block and review guard
1482
+ read, and names the repository its brief works in as `repo`
1483
+ (`_brief_repo`) when that is another one. `child` is the id the host
1484
+ returned for a background child (Claude's `agentId`)."""
1485
+ from tezgah_paths import _toplevel, off
1486
+ if off("slices-off") or not cwd:
1487
+ return []
1488
+ root = _toplevel(os.path.realpath(cwd))
1489
+ if not root:
1490
+ return []
1491
+ here = _journal_root(root)
1492
+ out = []
1493
+ context = str(inp.get("context") or "").strip() if isinstance(inp, dict) else ""
1494
+ for sid, agent, brief in spawned(tool, inp):
1495
+ repo = _brief_repo(brief, here)
1496
+ rec = open_slice(root, sid, host=host, agent=agent, brief=brief,
1497
+ parent=session_id or "", cwd=cwd,
1498
+ repo="" if repo == here else repo,
1499
+ task=brief[len(context):].strip() if context else brief,
1500
+ child=child)
1501
+ if failed is True:
1502
+ # a Claude subagent runs on Anthropic unless its model names another
1503
+ # vendor; another host's input may name a model
1504
+ stop = {"errorMessage": result_text, "model": str(inp.get("model") or ""),
1505
+ "default": "anthropic" if host == "claude" else ""}
1506
+ rec = close_slice(root, rec["id"], "stopped",
1507
+ classify(result_text) or "error", stop)
1508
+ elif failed is False:
1509
+ rec = close_slice(root, rec["id"], "done")
1510
+ out.append(rec)
1511
+ return out
1512
+
1513
+
1514
+ # An absolute path in a brief: a slash after a space, a quote or a bracket, up
1515
+ # to the next space, quote, bracket or separator.
1516
+ _ABS_PATH = re.compile(r"(?<![\w.~/-])/[^\s'\"`<>()\[\]{},;|*?]+")
1517
+
1518
+
1519
+ def main_repo(path):
1520
+ """The main checkout of the repository holding `path`, else None. A path
1521
+ under a repository's own `.tezgah` belongs to the repository around it
1522
+ (`_toplevel`), and a worktree to its main checkout."""
1523
+ from tezgah_paths import _toplevel
1524
+ top = _toplevel(os.path.realpath(path))
1525
+ return _journal_root(top) if top else None
1526
+
1527
+
1528
+ def _brief_repo(brief, root):
1529
+ """The main checkout of the one armed repository every absolute path the
1530
+ brief names lies in, else `root`. A brief naming two repositories, or none,
1531
+ keeps `root` (research orchestrator-flow-audit S7, finding 2)."""
1532
+ from tezgah_paths import root_for
1533
+ found = set()
1534
+ for path in _ABS_PATH.findall(brief or ""):
1535
+ top = main_repo(path.rstrip(".:"))
1536
+ if top and root_for(top):
1537
+ found.add(top)
1538
+ return found.pop() if len(found) == 1 else root
1539
+
1540
+
1541
+ def _omp_session_dir(parent):
1542
+ """The omp session directory of parent session `parent`
1543
+ (`~/.omp/agent/sessions/<project>/<stamp>_<parent>/`, one `<agent id>.jsonl`
1544
+ per subagent), or None. One glob over every project, so it runs once per
1545
+ record (`open_slice`), never per prompt."""
1546
+ import glob
1547
+ if not parent or "/" in parent or os.sep in parent:
1548
+ return None
1549
+ home = os.path.expanduser("~")
1550
+ hits = glob.glob(os.path.join(home, ".omp", "agent", "sessions", "*",
1551
+ "*_" + glob.escape(parent)))
1552
+ return hits[0] if hits else None
1553
+
1554
+
1555
+ def _omp_transcript(rec):
1556
+ """The newest omp child transcript of the record: omp names a re-dispatch of
1557
+ the same agent name in one session `<name>-2.jsonl`, `<name>-3.jsonl`, so the
1558
+ highest suffix is the attempt the record now tracks - reading `<name>.jsonl`
1559
+ would close a re-opened record with the first run's end. None when the
1560
+ record names no session directory or the directory holds no such file. The
1561
+ file is named by the spawned `name`, which a record's own id may extend."""
1562
+ folder, sid = rec.get("transcript_dir"), rec.get("name") or rec.get("id")
1563
+ if not folder or not sid:
1564
+ return None
1565
+ try:
1566
+ names = os.listdir(folder)
1567
+ except OSError:
1568
+ return None
1569
+ best, rank = None, -1
1570
+ attempt = re.compile(re.escape(sid) + r"(?:-(\d+))?\.jsonl")
1571
+ for name in names:
1572
+ hit = attempt.fullmatch(name)
1573
+ n = int(hit.group(1) or 1) if hit else -1
1574
+ if n > rank:
1575
+ best, rank = os.path.join(folder, name), n
1576
+ return best
1577
+
1578
+
1579
+ def _transcript_end(path):
1580
+ """How an omp child transcript ended, read from its last SLICE_TAIL bytes:
1581
+ ("stopped", class) for an error, abort or length stop on the last assistant
1582
+ message, an error with that message third (`close_slice`'s `stop`);
1583
+ ("done", "") once it yielded, stopped normally or exited; None while
1584
+ it may still be running or cannot be read."""
1585
+ import json
1586
+ try:
1587
+ with open(path, "rb") as fh:
1588
+ fh.seek(0, os.SEEK_END)
1589
+ size = fh.tell()
1590
+ fh.seek(max(0, size - SLICE_TAIL))
1591
+ data = fh.read().decode("utf-8", "replace")
1592
+ except OSError:
1593
+ return None
1594
+ rows = []
1595
+ for line in data.splitlines():
1596
+ try:
1597
+ row = json.loads(line)
1598
+ except ValueError:
1599
+ continue # the first line of a cut tail, or a torn last one
1600
+ if isinstance(row, dict):
1601
+ rows.append(row)
1602
+ exited = yielded = False
1603
+ for row in reversed(rows):
1604
+ if row.get("type") == "custom" and row.get("customType") == "session_exit":
1605
+ exited = True
1606
+ msg = row.get("message") if isinstance(row.get("message"), dict) else None
1607
+ if not msg:
1608
+ continue
1609
+ if msg.get("role") == "toolResult" and msg.get("toolName") in ("yield",
1610
+ "submit_result"):
1611
+ yielded = True
1612
+ if msg.get("role") != "assistant":
1613
+ continue
1614
+ stop = msg.get("stopReason")
1615
+ if stop == "error":
1616
+ return "stopped", classify(msg.get("errorMessage")) or "error", msg
1617
+ if stop == "aborted":
1618
+ return "stopped", "aborted"
1619
+ if stop == "length":
1620
+ return "stopped", "cut_off"
1621
+ if yielded or exited or stop == "stop":
1622
+ return "done", ""
1623
+ return None
1624
+ return ("done", "") if yielded or exited else None
1625
+
1626
+
1627
+ # How long a `job` record stays pending: omp clamps a background call's
1628
+ # deadline to 3600 s, so a job opened longer ago has ended either way and its
1629
+ # line would only repeat itself on every prompt. Its result stays in the parent
1630
+ # transcript the record names, so it closes `stale`, not done.
1631
+ SLICE_JOB_SECONDS = 3600
1632
+
1633
+
1634
+ def _age(rec):
1635
+ """Seconds since the record last changed (`updated`, else `opened`), or None
1636
+ when neither reads as a record time."""
1637
+ import calendar
1638
+ import time
1639
+ stamp = str(rec.get("updated") or rec.get("opened") or "")
1640
+ try:
1641
+ then = calendar.timegm(time.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ"))
1642
+ except ValueError:
1643
+ return None
1644
+ return time.time() - then
1645
+
1646
+
1647
+ def pending(root):
1648
+ """The records still owed work - status open or stopped - newest first. An
1649
+ omp record still open whose child transcript has ended is closed first:
1650
+ stopped with its failure class, or done when it ended normally. An open
1651
+ record nothing has moved for SLICE_IDLE_SECONDS (an omp child's transcript
1652
+ included) - or a `job` past SLICE_JOB_SECONDS - closes `stale`. One stat and
1653
+ at most one SLICE_TAIL read per open omp record."""
1654
+ out = []
1655
+ for rec in slices(root):
1656
+ if rec.get("status") != "open":
1657
+ if rec.get("status") == "stopped":
1658
+ out.append(rec)
1659
+ continue
1660
+ idle, limit = _age(rec), SLICE_IDLE_SECONDS
1661
+ if rec.get("kind") == "job":
1662
+ limit = SLICE_JOB_SECONDS
1663
+ elif rec.get("host") == "omp":
1664
+ if not rec.get("transcript_dir") and idle is not None and idle < 600:
1665
+ # the parent's directory did not exist yet when the record
1666
+ # opened: one more glob while the record is young, then kept
1667
+ found = _omp_session_dir(rec.get("parent"))
1668
+ if found:
1669
+ rec["transcript_dir"] = found
1670
+ _write_record(root, rec)
1671
+ path = _omp_transcript(rec)
1672
+ end = _transcript_end(path) if path else None
1673
+ if end:
1674
+ rec = close_slice(root, rec["id"], *end) or rec
1675
+ elif path:
1676
+ try:
1677
+ import time
1678
+ idle = min(idle if idle is not None else 1e18,
1679
+ time.time() - os.stat(path).st_mtime)
1680
+ except OSError:
1681
+ pass
1682
+ if rec.get("status") == "open" and idle is not None and idle > limit:
1683
+ rec = close_slice(root, rec["id"], "stale") or rec
1684
+ if rec.get("status") in ("open", "stopped"):
1685
+ out.append(rec)
1686
+ return out
1687
+
1688
+
1689
+ def _cut(line, keep=""):
1690
+ """`line` + `keep` cut to SLICE_LINE characters, the cut taken from `line` so
1691
+ `keep` (the command that acts on the record) survives whole."""
1692
+ room = SLICE_LINE - len(keep)
1693
+ if len(line) > room:
1694
+ line = line[:max(0, room - 3)] + "..."
1695
+ return (line + keep)[:SLICE_LINE]
1696
+
1697
+
1698
+ def slice_line(rec):
1699
+ """One bounded line (SLICE_LINE characters) for one record."""
1700
+ sid = rec.get("id") or ""
1701
+ status = rec.get("status") or ""
1702
+ if rec.get("kind") == "job":
1703
+ return _cut("- job %s %s: %s (parent session %s) - read its result there "
1704
+ "before re-running" % (sid, status,
1705
+ " ".join(str(rec.get("brief") or "").split())[:60],
1706
+ rec.get("parent") or "?"))
1707
+ head = "- slice %s (%s/%s) %s%s attempts %s" % (
1708
+ sid, rec.get("host") or "?", rec.get("agent") or "?", status,
1709
+ (" " + rec["failure"]) if rec.get("failure") else "", rec.get("attempts") or 1)
1710
+ tree = rec.get("worktree") or ""
1711
+ if rec.get("repo"):
1712
+ head += " (in %s)" % os.path.basename(rec["repo"])
1713
+ if tree and os.path.isdir(tree):
1714
+ head += " worktree %s @ %s" % (tree, _git(tree, "rev-parse", "--short", "HEAD") or "?")
1715
+ return _cut(head, ": tezgah-task slice resume %s" % sid)
1716
+
1717
+
1718
+ def slice_lines(root, limit=3, session=None, others=False):
1719
+ """One line per pending record, newest first, at most `limit` of them; with
1720
+ `session`, only the records that session spawned (`parent`) or that no
1721
+ session owns (one opened by hand with no `--parent`) - another session's
1722
+ slices are its own to resume. With `others`, one closing line counts those
1723
+ others, so a new session after a crash or a host switch still learns they
1724
+ exist (the resume block asks for it, the per-prompt part does not)."""
1725
+ recs = pending(root)
1726
+ if session is None:
1727
+ return [slice_line(rec) for rec in recs[:limit]]
1728
+ mine = [r for r in recs if r.get("parent") in (session, "", None)]
1729
+ out = [slice_line(rec) for rec in mine[:limit]]
1730
+ if others and len(recs) > len(mine):
1731
+ out.append("- %d pending slice(s) of other sessions: tezgah-task slice reconcile"
1732
+ % (len(recs) - len(mine)))
1733
+ return out