@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
package/bin/tezgah-task CHANGED
@@ -8,6 +8,16 @@
8
8
  tezgah-task status [--json] the active task, then every open plan: progress, what it waits on, what can run at once
9
9
  tezgah-task review <NNN|path> <reviewer> approve|changes record a fresh review's verdict
10
10
  tezgah-task close <NNN|path> done|discarded [--resolved TEXT] move a plan to .tezgah/plans/done, deactivate it, remove merged worktrees
11
+ tezgah-task tick <NNN|path> <n> [--undo] tick (or untick) Acceptance item n, counted from 1
12
+ tezgah-task note <NNN|path> state|next "<text>" replace the body of `## State` (the newest one) or `## Next`
13
+ tezgah-task slice open --id ID [--kind agent|job] [--host H] [--agent A] [--brief TEXT|--brief-file F] [--parent P] [--cwd PATH]
14
+ record a delegated slice (a re-open counts one more attempt)
15
+ tezgah-task slice done ID | slice stop ID [--class C] close a slice record
16
+ tezgah-task slice reconcile [--prune] every open or stopped slice with its resume brief; --prune also removes landed worktrees and branches
17
+ tezgah-task slice resume ID the native resume path, else a re-dispatch brief; marks the record resumed
18
+ tezgah-task slice stats [--window DAYS] [--json] counts per status, failure class and host, and the RSI lever candidates
19
+ tezgah-task slice board [--parent P] the open sibling slices: what each owns, its worktree, branch and last commit
20
+ tezgah-task slice merge-plan [--base REF] the done slice branches in merge order, with their forecast conflicts
11
21
 
12
22
  The record is the plan file itself: `phase:` activates it, `allowed_paths:` is
13
23
  the scope a write has to stay inside, and at most one open plan carries a phase
@@ -17,6 +27,9 @@ and this CLI is the record's only writer: the gate refuses a write to the plan
17
27
  file, because the checks below are made here and a retyped frontmatter would
18
28
  skip them. `phase implementation` (or `verification`) is what lets a write
19
29
  through, so the phase changes when the work reaches that point and not before.
30
+ `tick` and `note` are the record's body writers for the same reason: they write
31
+ an Acceptance box or the State/Next text on any open plan, active or not, keep
32
+ the frontmatter but `updated:`, and commit nothing.
20
33
 
21
34
  The allowlist is the plan's own: plan-add writes `allowed_paths:` when it
22
35
  creates the plan, and `start` without `--allow` keeps it. A writing phase with
@@ -61,6 +74,30 @@ still HEAD and the tree is still dirty, so committing is the whole way out of it
61
74
  and the commit clears the refusal by itself; the per-file snapshots
62
75
  (bin/tezgah-rollback) are not one state to return to.
63
76
 
77
+ `slice` keeps the slice journal (hooks/tezgah_task.py): one record per delegated
78
+ slice under `.tezgah/slices/` of the main checkout, opened by the post-tool path
79
+ on a spawn call or by `slice open`, so a parent crash, an omp `/clear`, a quota
80
+ stop or a model or host switch loses no slice. `reconcile` reads each open omp
81
+ record's child transcript and closes the ones that ended, then prints one
82
+ resume brief per open or stopped slice; `--prune` first runs the same worktree
83
+ removal `close` does, deletes (`git branch -d`) every local branch that has
84
+ landed and is checked out nowhere - never main, master or the current branch -
85
+ and marks done each record whose branch landed with a commit of its own.
86
+ `resume` prints the host's native path first (omp `write agent://<id>` while
87
+ the parent session lives, Claude `SendMessage`), then a brief any host or model
88
+ can re-dispatch: the original brief plus the last commit, the branch and the
89
+ uncommitted files. `stats` flags a `class+host` signature at 3 events, or at 5%
90
+ of a window holding 20 or more slices, and names its lever from a closed set.
91
+ `board` lists the open slices one parent spawned - the session's own, or for a
92
+ subagent its parent's - with the `Owns:` globs of each brief (the lease the
93
+ gate holds siblings to), worktree, branch and last commit. `merge-plan` forecasts
94
+ each done slice branch against `--base` (HEAD) and against the others with `git
95
+ merge-tree`, leaves out a `merge=union` file such as CHANGELOG.md, and orders
96
+ them with a branch touching MANIFEST or the citation baseline last, then fewest
97
+ conflicts first; a branch merge-tree cannot forecast (git before 2.38, no shared
98
+ history) is printed as `not judged` and the exit is 1. `review ... approve`
99
+ is refused while a record whose id or agent is that reviewer is not done.
100
+
64
101
  Exit codes: 0 ok, 1 a refusal or a problem - one `FAIL <what>` line per problem,
65
102
  with nothing written - 2 misuse.
66
103
  """
@@ -75,6 +112,7 @@ import time
75
112
  HERE = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
76
113
  sys.path.insert(0, os.path.join(HERE, "hooks"))
77
114
  import tezgah_task as tt # noqa: E402
115
+ from tezgah_paths import say # noqa: E402
78
116
 
79
117
 
80
118
  def repo_root(path):
@@ -331,6 +369,18 @@ def changed_lines(root, base):
331
369
  return sum(int(n) for n in re.findall(r"(\d+) (?:insertion|deletion)", stat))
332
370
 
333
371
 
372
+ # The checks deferred out of the turn to the merge step (AGENTS.md "Checks, by
373
+ # tier"): (the file that marks the repo has it, the command), in run order.
374
+ MERGE_CHECKS = (
375
+ ("tests/impacted.py", "python3 tests/impacted.py --all"),
376
+ ("tests/neuter_matrix.py", "TEZGAH_E2E_STRICT=1 python3 tests/neuter_matrix.py"),
377
+ ("bin/tezgah-docs", "python3 bin/tezgah-docs --citations"),
378
+ ("bin/tezgah-docs", "python3 bin/tezgah-docs --clarity"),
379
+ ("skills/plan-add/render_table.py",
380
+ "python3 skills/plan-add/render_table.py --acceptance --strict"),
381
+ )
382
+
383
+
334
384
  def verification_steps(text, path):
335
385
  """What the verification phase requires before the plan is done: the plan's
336
386
  own acceptance commands run once on the final tree, unpiped, and ONE review
@@ -358,8 +408,12 @@ def verification_steps(text, path):
358
408
  "no state go out as tool calls in ONE message, one suite run at a time:")
359
409
  for item in items or ["(the plan has no Acceptance items)"]:
360
410
  print(" - %s" % item)
361
- if os.path.isfile(os.path.join(root, "tests", "impacted.py")):
362
- print(" the one full run here is `python3 tests/impacted.py --all`")
411
+ # the deferred tier: never inside a turn, run once here at the merge step
412
+ deferred = [cmd for rel, cmd in MERGE_CHECKS
413
+ if os.path.isfile(os.path.join(root, rel))]
414
+ if deferred:
415
+ print(" the merge step's one run of the deferred checks: %s"
416
+ % ", ".join("`%s`" % c for c in deferred))
363
417
  if rounds and review_verdict(text) == "approve":
364
418
  print(" 2. the review approved in round %d at %s: no further round"
365
419
  % (rounds, reviewed))
@@ -368,9 +422,10 @@ def verification_steps(text, path):
368
422
  if rounds >= REVIEW_ROUNDS:
369
423
  print(" 2. the %d-round budget is spent: `tezgah-task review` refuses a "
370
424
  "third round. Fix a critical or major finding still confirmed "
371
- "yourself, prove it with a test and close with `--resolved`; a "
372
- "minor or suggestion one is recorded fix-later under the plan's "
373
- "## State" % REVIEW_ROUNDS)
425
+ "yourself, prove it with a test and close with `--resolved`; fix a "
426
+ "minor or suggestion one now, or record it fix-later under the "
427
+ "plan's ## State only when it waits on a user decision (%s)"
428
+ % (REVIEW_ROUNDS, say()["decision"]))
374
429
  return
375
430
  if rounds:
376
431
  print(" 2. review round 2 (the last): each confirmed critical or major "
@@ -392,8 +447,10 @@ def verification_steps(text, path):
392
447
  "security reviewer" % (REVIEW_ROUNDS, base, SMALL_DIFF,
393
448
  "unknown" if size is None else size))
394
449
  print(" only a confirmed critical or major finding is `changes` and "
395
- "opens round 2; a minor or suggestion one is recorded fix-later "
396
- "under ## State and the verdict is `approve`")
450
+ "opens round 2; a minor or suggestion one is fixed in the session "
451
+ "without a new round, or recorded fix-later under ## State only "
452
+ "when it waits on a user decision (%s), and the verdict is "
453
+ "`approve`" % say()["decision"])
397
454
  print(" 3. record its verdict: `tezgah-task review %s <reviewer> approve|changes`"
398
455
  " (records the reviewed sha);"
399
456
  " `tezgah-task close %s done` refuses without an approve" % (ident, ident))
@@ -418,7 +475,7 @@ def cmd_start(args):
418
475
  if not args:
419
476
  return misuse("start needs a plan id or path")
420
477
  root = repo_root(os.getcwd())
421
- path, problem = plan_path(root, args[0])
478
+ path, problem = open_plan(args[0])
422
479
  phase = None
423
480
  allow = []
424
481
  any_path = False
@@ -562,6 +619,70 @@ def cmd_stop(args):
562
619
  return show(task["path"])
563
620
 
564
621
 
622
+ def open_plan(ref):
623
+ """(path, problem) for a plan under `.tezgah/plans/open/`: `tick` and `note`
624
+ write past the gate, so they take no other file (review of plan 083, F4:
625
+ `note README.md next ...` rewrote any markdown file with a Next section)."""
626
+ root = repo_root(os.getcwd())
627
+ path, problem = plan_path(root, ref)
628
+ if problem:
629
+ return None, problem
630
+ if os.path.dirname(os.path.realpath(path)) != os.path.realpath(open_dir(root)):
631
+ return None, "%s is not an open plan under %s" % (path, open_dir(root))
632
+ return path, None
633
+
634
+
635
+ def cmd_tick(args):
636
+ """Tick (or `--undo` untick) one Acceptance box: the plan's progress is the
637
+ session's to record, and the gate refuses a direct write to the record."""
638
+ undo = "--undo" in args
639
+ args = [a for a in args if a != "--undo"]
640
+ if len(args) != 2 or not args[1].isdigit():
641
+ return misuse("tick takes <NNN|path> <n> [--undo]")
642
+ path, problem = open_plan(args[0])
643
+ if problem:
644
+ return problems([problem])
645
+ items = tt.acceptance_items(read(path))
646
+ n = int(args[1])
647
+ if not 1 <= n <= len(items):
648
+ return problems(["%s has %d Acceptance items, not an item %d"
649
+ % (path, len(items), n)])
650
+ item = items[n - 1]
651
+ try:
652
+ tt.set_box(path, item["line"], not undo)
653
+ except OSError as exc:
654
+ return problems(["cannot write %s: %s" % (path, exc)])
655
+ bad = write_fields(path, updated=today())
656
+ if bad:
657
+ return problems(bad)
658
+ print("- [%s] %s" % (" " if undo else "x", item["text"]))
659
+ return 0
660
+
661
+
662
+ NOTE_SECTIONS = {"state": "State", "next": "Next"}
663
+
664
+
665
+ def cmd_note(args):
666
+ """Replace the plan's `## State` (the newest one) or `## Next` body."""
667
+ if len(args) != 3 or args[1] not in NOTE_SECTIONS or not args[2].strip():
668
+ return misuse('note takes <NNN|path> state|next "<text>"')
669
+ path, problem = open_plan(args[0])
670
+ if problem:
671
+ return problems([problem])
672
+ heading = NOTE_SECTIONS[args[1]]
673
+ try:
674
+ found = tt.set_section(path, heading, args[2])
675
+ except OSError as exc:
676
+ return problems(["cannot write %s: %s" % (path, exc)])
677
+ if not found:
678
+ return problems(["%s has no `## %s` section" % (path, heading)])
679
+ bad = write_fields(path, updated=today())
680
+ if bad:
681
+ return problems(bad)
682
+ print("%s: ## %s updated" % (path, heading))
683
+ return 0
684
+
685
+
565
686
  def review_verdict(text):
566
687
  """The verdict of the plan's `review: <reviewer> <verdict> <date>` line, or
567
688
  None when it has none or names a verdict this CLI does not write."""
@@ -575,15 +696,59 @@ def budget_spent(path, ident):
575
696
  return ("%s has spent its %d-round review budget: never a third round. Fix a "
576
697
  "critical or major finding still confirmed yourself and prove it with "
577
698
  "a test, then `tezgah-task close %s done --resolved \"<the fix and the "
578
- "test that proves it>\"`; record a minor or suggestion one fix-later "
579
- "under the plan's ## State; `close %s discarded` needs no review"
580
- % (path, REVIEW_ROUNDS, ident, ident))
699
+ "test that proves it>\"`; fix a minor or suggestion one now, or record "
700
+ "it fix-later under the plan's ## State only when it waits on a "
701
+ "user decision (%s); `close %s discarded` needs no review"
702
+ % (path, REVIEW_ROUNDS, ident, say()["decision"], ident))
703
+
704
+
705
+ def unfinished_review(root, reviewer, plan="", since=0):
706
+ """The refusal of an `approve` while a slice journal record whose id or agent
707
+ is `reviewer` is open, stopped or stale: a review slice that died on a
708
+ provider limit had its approve recorded anyway, and a NameError it would have
709
+ caught reached main (plan 078). A stale review is one whose end nobody saw,
710
+ which reads no more than a stopped one. The omp records are reconciled with
711
+ their transcripts first (`tt.pending`), so a review that finished reads as
712
+ done. A record that names the plan active when it opened counts only for
713
+ that `plan`; one that names none counts when it changed at or after `since`
714
+ (epoch seconds; the plan's checkpoint commit). A `resumed` record has handed
715
+ its work to the re-dispatch, whose own record is the one read. A record
716
+ spawned as `reviewer` under its own id (`Reviewer-<digest6>`,
717
+ `tt.open_slice`) counts too. Under TEZGAH_SESSION only this session's
718
+ records and unowned ones count, by id, agent or name alike: another
719
+ session's review is not this approve's. The refusal names the exact id,
720
+ which `slice resume` and `slice done` take from any session."""
721
+ from tezgah_paths import off
722
+ if off("slices-off"):
723
+ return []
724
+ tt.pending(root)
725
+ session = os.environ.get("TEZGAH_SESSION")
726
+
727
+ def named(rec):
728
+ return reviewer in (rec.get("id"), rec.get("agent"), rec.get("name")) and (
729
+ not session or rec.get("parent") in (session, "", None))
730
+
731
+ def ours(rec):
732
+ if rec.get("plan"):
733
+ return rec["plan"] == plan
734
+ return (_stamp(rec.get("updated")) or 0) >= since
735
+
736
+ return ["review slice %s is %s%s: let it finish, or resume it with `tezgah-task "
737
+ "slice resume %s` and wait for that run; if the review was finished "
738
+ "another way, record it with `tezgah-task slice done %s` - an approve "
739
+ "from a review that did not finish reads nothing"
740
+ % (rec["id"], rec.get("status"),
741
+ (" (%s)" % rec["failure"]) if rec.get("failure") else "",
742
+ rec["id"], rec["id"])
743
+ for rec in tt.slices(root)
744
+ if rec.get("status") in ("open", "stopped", "stale")
745
+ and named(rec) and ours(rec)]
581
746
 
582
747
 
583
748
  def cmd_review(args):
584
749
  if len(args) != 3 or args[2] not in VERDICTS:
585
750
  return misuse("review takes <NNN|path> <reviewer> %s" % "|".join(VERDICTS))
586
- path, problem = plan_path(repo_root(os.getcwd()), args[0])
751
+ path, problem = open_plan(args[0])
587
752
  if problem:
588
753
  return problems([problem])
589
754
  fields = tt.frontmatter(read(path))
@@ -593,6 +758,17 @@ def cmd_review(args):
593
758
  # the project's HEAD, never the private `.tezgah` repository's: the next
594
759
  # round's delta is `git diff <this>..HEAD` over the project
595
760
  root = os.path.realpath(os.path.join(os.path.dirname(path), "..", "..", ".."))
761
+ if args[2] == "approve":
762
+ # the plan's checkpoint commit (a sha, or `pending <sha>`) dates the work
763
+ # this review is about; with none, every record counts
764
+ mark = (fields.get("checkpoint") or "").split()
765
+ since = (git_out(root, "log", "-1", "--format=%ct", mark[-1])
766
+ if mark and mark[-1] != tt.CHECKPOINT_PENDING else None)
767
+ ident = fields.get("id") or os.path.basename(path).split("-")[0]
768
+ bad = unfinished_review(root, args[1], ident,
769
+ int(since) if (since or "").isdigit() else 0)
770
+ if bad:
771
+ return problems(bad)
596
772
  sha = (git_out(root, "rev-parse", "HEAD") or "")[:12]
597
773
  record = {"review": "%s %s %s" % (args[1], args[2], today()),
598
774
  "review_round": str(int(prior) + 1 if prior.isdigit() else 1),
@@ -716,6 +892,15 @@ def _keeps(root, path, branch, bases, agents):
716
892
  return None
717
893
 
718
894
 
895
+ def landing_bases(root):
896
+ """The refs a branch's work counts as landed in, those that exist: HEAD,
897
+ main, master, and the remote's default branch too - a PR merged on the host
898
+ lands there first."""
899
+ return [b for b in ("HEAD", "main", "master", "origin/HEAD", "origin/main",
900
+ "origin/master")
901
+ if git_out(root, "rev-parse", "-q", "--verify", b)]
902
+
903
+
719
904
  def prune_worktrees(root):
720
905
  """Remove every linked worktree of the repository whose branch has landed and
721
906
  which holds nothing else, so a closed plan leaves no checkout behind; one line
@@ -741,10 +926,7 @@ def prune_worktrees(root):
741
926
  for p in linked]
742
927
  orca = {os.path.realpath(r.get("path") or ""): r for r in rows or []
743
928
  if isinstance(r, dict)}
744
- # the remote's default branch too: a PR merged on the host lands there first
745
- bases = [b for b in ("HEAD", "main", "master", "origin/HEAD", "origin/main",
746
- "origin/master")
747
- if git_out(root, "rev-parse", "-q", "--verify", b)]
929
+ bases = landing_bases(root)
748
930
  out = []
749
931
  for path in linked:
750
932
  branch = git_out(path, "symbolic-ref", "-q", "--short", "HEAD")
@@ -826,6 +1008,390 @@ def cmd_status(args):
826
1008
  return 1 if bad else 0
827
1009
 
828
1010
 
1011
+ # ------------------------------------------------------------------ slice
1012
+
1013
+ # The closed lever set a stats candidate is named from (the research line's
1014
+ # RSI loop): one file or mechanism per failure class, never prose.
1015
+ LEVERS = {"rate_limit": "hooks/tezgah_models.py fallback chain",
1016
+ "quota": "hooks/tezgah_models.py fallback chain",
1017
+ "cut_off": "per-slice deadline", "timeout": "per-slice deadline",
1018
+ "aborted": "wip: commit cadence", "duplicate": "journal background jobs",
1019
+ "error": "none (read the transcript)"}
1020
+ STATS_WINDOW = 14
1021
+ # A signature is a candidate at this many events, or at this share of the slices
1022
+ # in the window once the window holds STATS_SHARE_FLOOR slices: below that, one
1023
+ # event is already 5% and every stop would read as a pattern.
1024
+ STATS_EVENTS = 3
1025
+ STATS_SHARE = 0.05
1026
+ STATS_SHARE_FLOOR = 20
1027
+
1028
+
1029
+ def parse_options(args, valued, flags=()):
1030
+ """(options, positionals, problem) for `args`: each `valued` option takes the
1031
+ next argument, each flag stands alone, any other `--x` is a problem."""
1032
+ opts, pos, i = {}, [], 0
1033
+ while i < len(args):
1034
+ arg = args[i]
1035
+ if arg in valued:
1036
+ if i + 1 >= len(args):
1037
+ return None, None, "%s needs a value" % arg
1038
+ opts[arg] = args[i + 1]
1039
+ i += 2
1040
+ elif arg in flags:
1041
+ opts[arg] = True
1042
+ i += 1
1043
+ elif arg.startswith("--"):
1044
+ return None, None, "unknown option %r" % arg
1045
+ else:
1046
+ pos.append(arg)
1047
+ i += 1
1048
+ return opts, pos, None
1049
+
1050
+
1051
+ def resume_text(root, rec):
1052
+ """The way back into one record's work: the host's native path first (omp
1053
+ `write agent://<id>` while the parent session lives, Claude `SendMessage`),
1054
+ else a re-dispatch brief that runs on any host or model - the original brief
1055
+ plus where the work stands (last commit, branch, uncommitted files)."""
1056
+ sid = rec["id"]
1057
+ lines = [tt.slice_line(rec)]
1058
+ if rec.get("kind") == "job":
1059
+ lines.append(" command: %s" % (rec.get("brief") or "(none recorded)"))
1060
+ return "\n".join(lines)
1061
+ tree = rec.get("worktree") or ""
1062
+ cwd = rec.get("cwd") or ""
1063
+ where = (tree if tree and os.path.isdir(tree) else
1064
+ cwd if cwd and os.path.isdir(cwd) else root)
1065
+ last = git_out(where, "log", "-1", "--format=%h %s") or "(none)"
1066
+ branch = (rec.get("branch") or git_out(where, "symbolic-ref", "-q", "--short", "HEAD")
1067
+ or "(detached HEAD)")
1068
+ # the journal's own directory is not the slice's work, ignored or not
1069
+ dirty = [line[3:] for line in (git_out(where, "status", "--porcelain") or "").splitlines()
1070
+ if not line[3:].startswith(".tezgah")]
1071
+ resume = ("Resume: last commit %s on %s; uncommitted: %s; continue from there, "
1072
+ "do not redo committed steps" % (last, branch, ", ".join(dirty) or "none"))
1073
+ # a Claude record named by a 12-hex brief digest addresses no agent: no
1074
+ # SendMessage line for it, only the re-dispatch
1075
+ named = not re.fullmatch(r"[0-9a-f]{12}", sid)
1076
+ native = {"omp": "`write agent://%s` (while omp session %s still runs)"
1077
+ % (sid, rec.get("parent") or "?"),
1078
+ "claude": "`SendMessage` to %s" % sid if named else None}.get(rec.get("host"))
1079
+ if native:
1080
+ lines.append(" native: %s with: %s" % (native, resume))
1081
+ provider = (" on another provider (`tezgah-route` names the tier; the same "
1082
+ "account's limit would stop it again)"
1083
+ if rec.get("failure") in ("quota", "rate_limit") else
1084
+ " on any host or model")
1085
+ lines.append(" %sre-dispatch%s with this brief:" % ("else " if native else "", provider))
1086
+ lines.append(rec.get("brief") or "(no brief recorded)")
1087
+ lines.append(resume)
1088
+ return "\n".join(lines)
1089
+
1090
+
1091
+ def own_commits(root, rec):
1092
+ """Does the record's branch carry a commit past the base it was opened on?
1093
+ A branch still at its base is a slice that has not started, not one whose
1094
+ work landed."""
1095
+ base, branch = rec.get("base"), rec.get("branch")
1096
+ if not base or not branch:
1097
+ return False
1098
+ count = git_out(root, "rev-list", "--count", "%s..%s" % (base, branch))
1099
+ return bool(count) and count != "0"
1100
+
1101
+
1102
+ def prune_branches(root, bases):
1103
+ """`git branch -d` every local branch whose work landed in `bases`, that no
1104
+ worktree has checked out, that tracks no upstream (a long-lived branch such
1105
+ as `develop` tracks one) and that carries a commit of its own (its reflog
1106
+ names one, the guard `_keeps` applies to a worktree: a pre-created branch
1107
+ nobody committed on is a slice not yet started) - never main, master or the
1108
+ current branch. One line per branch it deletes or git refuses."""
1109
+ current = git_out(root, "symbolic-ref", "-q", "--short", "HEAD")
1110
+ held = {line[len("branch refs/heads/"):] for line in
1111
+ (git_out(root, "worktree", "list", "--porcelain") or "").splitlines()
1112
+ if line.startswith("branch refs/heads/")}
1113
+ out = []
1114
+ for row in (git_out(root, "for-each-ref", "--format=%(refname:short)\t%(upstream)",
1115
+ "refs/heads") or "").splitlines():
1116
+ branch, _tab, upstream = row.partition("\t")
1117
+ if branch in ("main", "master", current) or branch in held or upstream:
1118
+ continue
1119
+ log = git_out(root, "reflog", "show", "--format=%gs", "refs/heads/" + branch) or ""
1120
+ if not any(entry.startswith(("commit:", "commit (amend):", "commit (initial):"))
1121
+ for entry in log.splitlines()):
1122
+ continue
1123
+ if not any(_landed(root, branch, b) for b in bases):
1124
+ continue
1125
+ done = subprocess.run(["git", "-C", root, "branch", "-d", branch],
1126
+ capture_output=True, text=True)
1127
+ out.append("branch deleted: %s" % branch if done.returncode == 0 else
1128
+ "branch kept: %s (`git branch -d %s` refused: %s)"
1129
+ % (branch, branch, (done.stderr.strip().splitlines() or ["?"])[0]))
1130
+ return out
1131
+
1132
+
1133
+ def _stamp(value):
1134
+ """Seconds since the epoch of an ISO-8601 UTC record time, None when bad."""
1135
+ import calendar
1136
+ try:
1137
+ return calendar.timegm(time.strptime(str(value), "%Y-%m-%dT%H:%M:%SZ"))
1138
+ except ValueError:
1139
+ return None
1140
+
1141
+
1142
+ def slice_stats(rows, window, now=None):
1143
+ """Counts per status, failure class and host over the records updated in the
1144
+ last `window` days, and the candidate signatures: `class+host`, one event per
1145
+ record (the class it last stopped with) and `duplicate+host` per job record
1146
+ run more than once, at STATS_EVENTS events or STATS_SHARE of the slices."""
1147
+ from collections import Counter
1148
+ cutoff = (time.time() if now is None else now) - window * 86400
1149
+ picked = [r for r in rows if (_stamp(r.get("updated") or r.get("opened")) or 0) >= cutoff]
1150
+ sigs = Counter()
1151
+ for rec in picked:
1152
+ host = rec.get("host") or "?"
1153
+ if rec.get("failure") in LEVERS:
1154
+ sigs[(rec["failure"], host)] += 1
1155
+ if rec.get("kind") == "job" and int(rec.get("attempts") or 1) > 1:
1156
+ sigs[("duplicate", host)] += 1
1157
+ total = len(picked)
1158
+ candidates = [{"signature": "%s+%s" % key, "events": n,
1159
+ "share": round(n / total, 3), "lever": LEVERS[key[0]]}
1160
+ for key, n in sorted(sigs.items(), key=lambda kv: (-kv[1], kv[0]))
1161
+ if n >= STATS_EVENTS
1162
+ or (total >= STATS_SHARE_FLOOR and n >= STATS_SHARE * total)]
1163
+ return {"window_days": window, "slices": total,
1164
+ "status": dict(Counter(r.get("status") or "?" for r in picked)),
1165
+ "failure": dict(Counter(r["failure"] for r in picked if r.get("failure"))),
1166
+ "host": dict(Counter(r.get("host") or "?" for r in picked)),
1167
+ "candidates": candidates}
1168
+
1169
+
1170
+ def _counts(counter):
1171
+ return ", ".join("%s %d" % kv for kv in sorted(counter.items())) or "none"
1172
+
1173
+
1174
+ # Files every slice appends to and only the integrator resolves (research
1175
+ # orchestrator-flow-audit S6, finding 8): a branch that touches one merges
1176
+ # after every branch that does not.
1177
+ HOTSPOTS = ("MANIFEST", "docs/citations-baseline.json")
1178
+
1179
+
1180
+ def merge_conflicts(root, ours, theirs):
1181
+ """The files `git merge-tree` forecasts in conflict when `theirs` merges
1182
+ into `ours`, minus the ones a `merge=union` attribute merges (CHANGELOG.md
1183
+ here); None when git cannot answer (no such ref, git before 2.38)."""
1184
+ try:
1185
+ out = subprocess.run(["git", "-C", root, "merge-tree", "--write-tree",
1186
+ "--name-only", "--no-messages", ours, theirs],
1187
+ capture_output=True, text=True)
1188
+ except OSError:
1189
+ return None
1190
+ if out.returncode not in (0, 1):
1191
+ return None
1192
+ files = sorted(set(out.stdout.split("\n\n")[0].splitlines()[1:]))
1193
+ if not files:
1194
+ return []
1195
+ attrs = git_out(root, "check-attr", "merge", "--", *files) or ""
1196
+ union = {line.rsplit(": merge: ", 1)[0] for line in attrs.splitlines()
1197
+ if line.endswith(": merge: union")}
1198
+ return [f for f in files if f not in union]
1199
+
1200
+
1201
+ def merge_plan(root, base):
1202
+ """(lines, unjudged) for the done slices whose branch carries work `base`
1203
+ lacks. The lines are the order to merge them: a branch touching no
1204
+ HOTSPOTS first, then fewest forecast conflicts with `base`, then fewest
1205
+ siblings it overlaps; each names its conflicting files and the siblings it
1206
+ collides with. `unjudged` names each branch `git merge-tree` could not
1207
+ forecast (git before 2.38, no shared history) instead of dropping it."""
1208
+ branches = {}
1209
+ for rec in tt.slices(root):
1210
+ branch = rec.get("branch")
1211
+ if (rec.get("status") == "done" and branch and branch not in branches
1212
+ and own_commits(root, rec) and not _landed(root, branch, base)):
1213
+ branches[branch] = rec
1214
+ rows, unjudged = [], []
1215
+ for branch, rec in branches.items():
1216
+ mine = merge_conflicts(root, base, branch)
1217
+ if mine is None:
1218
+ unjudged.append("not judged: %s (slice %s): `git merge-tree --write-tree` "
1219
+ "could not forecast it against %s" % (branch, rec["id"], base))
1220
+ continue
1221
+ overlaps = {}
1222
+ for other in branches:
1223
+ files = merge_conflicts(root, other, branch) if other != branch else None
1224
+ if files:
1225
+ overlaps[other] = files
1226
+ touched = (git_out(root, "diff", "--name-only", "%s...%s" % (base, branch))
1227
+ or "").splitlines()
1228
+ hot = any(f in HOTSPOTS for f in touched)
1229
+ rows.append(((hot, len(mine), len(overlaps), branch), rec, mine, overlaps))
1230
+ rows.sort(key=lambda row: row[0])
1231
+ out = []
1232
+ for n, (key, rec, mine, overlaps) in enumerate(rows, 1):
1233
+ line = "%d. %s (slice %s): %d conflict(s) with %s%s" % (
1234
+ n, rec["branch"], rec["id"], len(mine), base,
1235
+ ": " + ", ".join(mine) if mine else "")
1236
+ for other, files in sorted(overlaps.items()):
1237
+ line += "; overlaps %s on %s" % (other, ", ".join(files))
1238
+ out.append(line + (" (touches a hotspot: the integrator merges it last)"
1239
+ if key[0] else ""))
1240
+ return out, unjudged
1241
+
1242
+
1243
+ def board_parent():
1244
+ """The parent whose slices `slice board` shows: the session in
1245
+ TEZGAH_SESSION, or - read from a subagent - the parent that spawned it
1246
+ (`tezgah_integrity.spawn_of`), so a slice sees its siblings; None, every
1247
+ open slice, outside a session."""
1248
+ session = os.environ.get("TEZGAH_SESSION")
1249
+ if not session:
1250
+ return None
1251
+ try:
1252
+ from tezgah_integrity import spawn_of
1253
+ spawn = spawn_of(session)
1254
+ except Exception:
1255
+ spawn = None
1256
+ return spawn[0] if spawn else session
1257
+
1258
+
1259
+ def cmd_slice(args):
1260
+ usage = ("slice takes open|done|stop|reconcile|resume|stats|board|merge-plan "
1261
+ "(open --id ID [--kind agent|job] [--host H] [--agent A] "
1262
+ "[--brief TEXT|--brief-file F] [--parent P] [--cwd PATH]; done ID; stop ID "
1263
+ "[--class C]; reconcile [--prune]; resume ID; stats [--window DAYS] "
1264
+ "[--json]; board [--parent P]; merge-plan [--base REF])")
1265
+ if not args:
1266
+ return misuse(usage)
1267
+ sub, rest = args[0], args[1:]
1268
+ root = repo_root(os.getcwd())
1269
+ if sub == "board":
1270
+ opts, pos, bad = parse_options(rest, ("--parent",))
1271
+ if bad or pos:
1272
+ return misuse(bad or "slice board takes [--parent P] and nothing else")
1273
+ lines = tt.board(root, opts.get("--parent") or board_parent())
1274
+ print("\n".join(lines) if lines else "no open slice")
1275
+ return 0
1276
+ if sub == "merge-plan":
1277
+ opts, pos, bad = parse_options(rest, ("--base",))
1278
+ if bad or pos:
1279
+ return misuse(bad or "slice merge-plan takes [--base REF] and nothing else")
1280
+ base = opts.get("--base", "HEAD")
1281
+ if git_out(root, "rev-parse", "-q", "--verify", base) is None:
1282
+ return problems(["no revision %s in %s" % (base, root)])
1283
+ lines, unjudged = merge_plan(root, base)
1284
+ if lines or not unjudged:
1285
+ print("\n".join(lines) if lines else "no done slice branch to merge into %s" % base)
1286
+ for line in unjudged:
1287
+ print(line)
1288
+ return 1 if unjudged else 0
1289
+ if sub == "open":
1290
+ opts, pos, bad = parse_options(rest, ("--id", "--kind", "--host", "--agent",
1291
+ "--brief", "--brief-file", "--parent",
1292
+ "--cwd"))
1293
+ if bad or pos:
1294
+ return misuse(bad or "slice open takes options only (%r)" % pos[0])
1295
+ if not opts.get("--id"):
1296
+ return misuse("slice open needs --id ID")
1297
+ if "--brief" in opts and "--brief-file" in opts:
1298
+ return misuse("--brief and --brief-file contradict each other")
1299
+ kind, host = opts.get("--kind", "agent"), opts.get("--host", "")
1300
+ if kind not in tt.SLICE_KINDS:
1301
+ return misuse("--kind is one of %s" % ", ".join(tt.SLICE_KINDS))
1302
+ if host and host not in tt.SLICE_HOSTS:
1303
+ return misuse("--host is one of %s" % ", ".join(tt.SLICE_HOSTS))
1304
+ brief = opts.get("--brief", "")
1305
+ if "--brief-file" in opts:
1306
+ try:
1307
+ brief = read(opts["--brief-file"])
1308
+ except (OSError, UnicodeDecodeError) as exc:
1309
+ return problems(["cannot read %s: %s" % (opts["--brief-file"], exc)])
1310
+ try:
1311
+ rec = tt.open_slice(root, opts["--id"], kind=kind, host=host,
1312
+ agent=opts.get("--agent", ""), brief=brief,
1313
+ parent=opts.get("--parent", ""),
1314
+ cwd=opts.get("--cwd") or os.getcwd())
1315
+ except OSError as exc:
1316
+ return problems(["cannot write the slice record: %s" % exc])
1317
+ print("slice %s open (%s, attempt %d)%s" % (
1318
+ rec["id"], rec["kind"], rec["attempts"],
1319
+ " worktree %s on %s" % (rec["worktree"], rec["branch"] or "?")
1320
+ if rec["worktree"] else ""))
1321
+ return 0
1322
+ if sub in ("done", "stop"):
1323
+ opts, pos, bad = parse_options(rest, ("--class",) if sub == "stop" else ())
1324
+ if bad or len(pos) != 1:
1325
+ return misuse(bad or "slice %s takes one ID%s" % (
1326
+ sub, " [--class C]" if sub == "stop" else ""))
1327
+ failure = opts.get("--class", "")
1328
+ if failure and failure not in tt.FAILURES:
1329
+ return misuse("--class is one of %s" % ", ".join(tt.FAILURES))
1330
+ status = "done" if sub == "done" else "stopped"
1331
+ try:
1332
+ found = tt.find_slice(root, pos[0], os.environ.get("TEZGAH_SESSION"))
1333
+ rec = found and tt.close_slice(root, found["id"], status, failure)
1334
+ except OSError as exc:
1335
+ return problems(["cannot write the slice record: %s" % exc])
1336
+ if not rec:
1337
+ return problems(["no slice record %s under %s" % (pos[0], tt.slice_dir(root))])
1338
+ print("slice %s %s%s" % (rec["id"], status,
1339
+ " (%s)" % rec["failure"] if rec.get("failure") else ""))
1340
+ return 0
1341
+ if sub == "reconcile":
1342
+ opts, pos, bad = parse_options(rest, (), ("--prune",))
1343
+ if bad or pos:
1344
+ return misuse(bad or "slice reconcile takes --prune and nothing else")
1345
+ if opts.get("--prune"):
1346
+ bases = landing_bases(root)
1347
+ # read before the prune: a removed worktree takes its branch with it
1348
+ landed = [rec for rec in tt.slices(root)
1349
+ if rec.get("status") != "done" and own_commits(root, rec)
1350
+ and any(_landed(root, rec["branch"], b) for b in bases)]
1351
+ for line in prune_worktrees(root) + prune_branches(root, bases):
1352
+ print(line)
1353
+ for rec in landed:
1354
+ tt.close_slice(root, rec["id"], "done")
1355
+ print("slice %s done: %s landed" % (rec["id"], rec["branch"]))
1356
+ rows = tt.pending(root)
1357
+ if not rows:
1358
+ print("no open or stopped slice")
1359
+ for rec in rows:
1360
+ print(resume_text(root, rec))
1361
+ return 0
1362
+ if sub == "resume":
1363
+ if len(rest) != 1:
1364
+ return misuse("slice resume takes one ID")
1365
+ rec = tt.find_slice(root, rest[0], os.environ.get("TEZGAH_SESSION"))
1366
+ if rec is None:
1367
+ return problems(["no slice record %s under %s" % (rest[0], tt.slice_dir(root))])
1368
+ print(resume_text(root, rec))
1369
+ tt.close_slice(root, rec["id"], "resumed")
1370
+ return 0
1371
+ if sub == "stats":
1372
+ opts, pos, bad = parse_options(rest, ("--window",), ("--json",))
1373
+ if bad or pos:
1374
+ return misuse(bad or "slice stats takes [--window DAYS] [--json]")
1375
+ window = opts.get("--window", str(STATS_WINDOW))
1376
+ if not window.isdigit() or int(window) < 1:
1377
+ return misuse("--window takes a whole number of days")
1378
+ stats = slice_stats(tt.slices(root), int(window))
1379
+ if opts.get("--json"):
1380
+ print(json.dumps(stats, indent=2, sort_keys=True))
1381
+ return 0
1382
+ print("slices in the last %d days: %d" % (stats["window_days"], stats["slices"]))
1383
+ for key in ("status", "failure", "host"):
1384
+ print(" %s: %s" % (key, _counts(stats[key])))
1385
+ for row in stats["candidates"]:
1386
+ print("candidate: %s - %d events (%d%% of slices) - lever: %s" % (
1387
+ row["signature"], row["events"], round(row["share"] * 100), row["lever"]))
1388
+ if not stats["candidates"]:
1389
+ print("no signature at %d events or %d%% of the slices"
1390
+ % (STATS_EVENTS, round(STATS_SHARE * 100)))
1391
+ return 0
1392
+ return misuse(usage)
1393
+
1394
+
829
1395
  def main(argv):
830
1396
  if len(argv) < 2:
831
1397
  print(__doc__, file=sys.stderr)
@@ -845,8 +1411,14 @@ def main(argv):
845
1411
  return cmd_review(rest)
846
1412
  if cmd == "close":
847
1413
  return cmd_close(rest)
1414
+ if cmd == "tick":
1415
+ return cmd_tick(rest)
1416
+ if cmd == "note":
1417
+ return cmd_note(rest)
1418
+ if cmd == "slice":
1419
+ return cmd_slice(rest)
848
1420
  return misuse("unknown command %r (start, phase, allow, stop, status, review, "
849
- "close)" % cmd)
1421
+ "close, tick, note, slice)" % cmd)
850
1422
 
851
1423
 
852
1424
  if __name__ == "__main__":