@r1z4x/tezgah 1.5.1 → 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 (81) hide show
  1. package/CHANGELOG.md +126 -1
  2. package/MANIFEST +11 -0
  3. package/README.md +6 -3
  4. package/bin/consult +13 -10
  5. package/bin/tezgah-docs +18 -16
  6. package/bin/tezgah-doctor +152 -0
  7. package/bin/tezgah-research +286 -12
  8. package/bin/tezgah-route +48 -1
  9. package/bin/tezgah-setup +27 -14
  10. package/bin/tezgah-task +771 -46
  11. package/bin/tezgah-taste +48 -10
  12. package/docs/architecture.md +7 -4
  13. package/docs/citations-baseline.json +1 -1
  14. package/docs/clarity-baseline.json +5 -5
  15. package/docs/contract.md +112 -25
  16. package/docs/evidence.md +201 -28
  17. package/docs/feature-audit.md +25 -4
  18. package/docs/gate.md +70 -33
  19. package/docs/glossary.md +4 -4
  20. package/docs/hosts.md +8 -8
  21. package/docs/index.json +30 -10
  22. package/docs/judge.md +9 -3
  23. package/docs/models.md +35 -2
  24. package/docs/operations.md +103 -3
  25. package/docs/research.md +392 -13
  26. package/docs/skills.md +24 -6
  27. package/docs/status-line.md +5 -5
  28. package/docs/testing.md +2 -2
  29. package/docs/vision.md +12 -4
  30. package/hooks/hooks.json +2 -2
  31. package/hooks/lang/en.json +323 -0
  32. package/hooks/lang/tr.json +344 -0
  33. package/hooks/projects-posttooluse.py +24 -5
  34. package/hooks/projects-pretooluse.py +9 -4
  35. package/hooks/projects-stop.py +28 -16
  36. package/hooks/tezgah_agents.py +29 -9
  37. package/hooks/tezgah_clarity.py +7 -3
  38. package/hooks/tezgah_components.py +2 -1
  39. package/hooks/tezgah_context.py +460 -390
  40. package/hooks/tezgah_gate.py +640 -104
  41. package/hooks/tezgah_integrity.py +1480 -197
  42. package/hooks/tezgah_models.py +337 -15
  43. package/hooks/tezgah_paths.py +103 -10
  44. package/hooks/tezgah_policy.py +286 -130
  45. package/hooks/tezgah_product.py +107 -0
  46. package/hooks/tezgah_rank.py +29 -27
  47. package/hooks/tezgah_replay.py +200 -30
  48. package/hooks/tezgah_research.py +1297 -12
  49. package/hooks/tezgah_skill_pick.py +58 -17
  50. package/hooks/tezgah_stopspec.py +7 -0
  51. package/hooks/tezgah_store.py +74 -27
  52. package/hooks/tezgah_task.py +917 -8
  53. package/hosts/codex/hook.py +9 -10
  54. package/hosts/cursor/hook.py +21 -14
  55. package/hosts/dsh/hooks.json +2 -2
  56. package/hosts/omp/hook.py +44 -23
  57. package/hosts/omp/tezgah-hook.ts.in +93 -7
  58. package/hosts/opencode/plugins/tezgah.js +119 -39
  59. package/output-styles/tezgah.md +76 -42
  60. package/package.json +1 -1
  61. package/skills/analyze-app/SKILL.md +57 -0
  62. package/skills/design-contract/EVALS.md +24 -1
  63. package/skills/design-contract/SKILL.md +17 -1
  64. package/skills/design-contract/data-views.md +86 -0
  65. package/skills/feature-audit/SKILL.md +32 -3
  66. package/skills/i-have-adhd/EVALS.md +22 -2
  67. package/skills/i-have-adhd/SKILL.md +12 -5
  68. package/skills/plan-add/EVALS.md +8 -8
  69. package/skills/plan-add/SKILL.md +22 -21
  70. package/skills/plan-sync/EVALS.md +2 -2
  71. package/skills/plan-sync/SKILL.md +14 -8
  72. package/skills/pm-frameworks/SKILL.md +6 -0
  73. package/skills/ponytail/EVALS.md +21 -2
  74. package/skills/ponytail/SKILL.md +4 -4
  75. package/skills/product-analysis/EVALS.md +23 -1
  76. package/skills/product-analysis/SKILL.md +50 -11
  77. package/skills/product-flow/EVALS.md +81 -0
  78. package/skills/product-flow/SKILL.md +91 -0
  79. package/skills/research/SKILL.md +99 -2
  80. package/skills/tezgah-contract/EVALS.md +52 -7
  81. package/skills/tezgah-contract/SKILL.md +204 -85
package/bin/tezgah-task CHANGED
@@ -3,35 +3,57 @@
3
3
 
4
4
  tezgah-task start <NNN|path> --phase P [--allow GLOB ... | --any-path] activate a plan
5
5
  tezgah-task phase P [--any-path] change the active task's phase
6
- tezgah-task allow GLOB ... | --any-path replace the allowlist
6
+ tezgah-task allow GLOB ... | --any-path --why REASON replace the allowlist, recording why
7
7
  tezgah-task stop deactivate (clear phase)
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
- tezgah-task close <NNN|path> done|discarded move a plan to .tezgah/plans/done
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
14
- because `start` clears the others. That makes the gate's refusal a rule the user
15
- set in advance, so this CLI is the only writer and the agent never runs its
16
- first four commands - `stop` and a hand edit are equally valid ways to take the
17
- requirement away. `phase implementation` (or `verification`) is what lets a write
24
+ because `start` clears the others. The orchestrating session runs every command
25
+ here itself as the work moves - the lifecycle is never handed back to the user -
26
+ and this CLI is the record's only writer: the gate refuses a write to the plan
27
+ file, because the checks below are made here and a retyped frontmatter would
28
+ skip them. `phase implementation` (or `verification`) is what lets a write
18
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.
19
33
 
20
34
  The allowlist is the plan's own: plan-add writes `allowed_paths:` when it
21
35
  creates the plan, and `start` without `--allow` keeps it. A writing phase with
22
36
  no allowlist is refused unless `--any-path` says so explicitly, and the record
23
37
  keeps where its scope came from (`allowed_from:`), which `status` prints.
24
38
 
25
- `review` and `close` are the session's: the verification phase asks for one
26
- review of the plan's whole diff, `review` records its verdict as
39
+ The verification phase asks for one review of the plan's whole diff: `review`
40
+ records its verdict as
27
41
  `review: <reviewer> <verdict> <date>` together with `reviewed_sha:` (the
28
42
  project's HEAD it read) and `review_round:`, so the next round reads only
29
43
  `git diff <reviewed_sha>..HEAD`, and refuses a third round: the budget is two
30
44
  rounds per plan; `close NNN done` refuses a plan whose review is missing or not
31
- `approve`. `close` refuses the active task - that one is the user's to stop first.
45
+ `approve`, unless both rounds are spent and `--resolved` records the fix the
46
+ session made and the test that proves it. `close` on the active task clears its phase in the same write, then
47
+ removes each linked worktree whose branch has landed in HEAD, `main`, `master`
48
+ or the remote's default branch (an ancestor of it, or a squash whose merge
49
+ would change nothing) and which holds nothing else (no uncommitted or unknown
50
+ ignored file, no `.tezgah` data, a branch someone committed on, no Orca agent
51
+ still at work; Orca down keeps them all); every worktree it keeps is printed
52
+ with the reason.
32
53
 
33
54
  Two things the plan owes are asked here, at the only moment they can still be
34
- fixed - the phase move - because the record is the user's and the refusal is
55
+ fixed - the phase move - because this CLI is the record's only writer and the
56
+ refusal is
35
57
  this CLI's: a writing phase needs every Acceptance item to name the command
36
58
  proving it (or say `unverifiable` and why), since the report CI runs afterwards
37
59
  refuses an open plan on any one item that does neither, through the same
@@ -52,6 +74,30 @@ still HEAD and the tree is still dirty, so committing is the whole way out of it
52
74
  and the commit clears the refusal by itself; the per-file snapshots
53
75
  (bin/tezgah-rollback) are not one state to return to.
54
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
+
55
101
  Exit codes: 0 ok, 1 a refusal or a problem - one `FAIL <what>` line per problem,
56
102
  with nothing written - 2 misuse.
57
103
  """
@@ -66,6 +112,7 @@ import time
66
112
  HERE = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
67
113
  sys.path.insert(0, os.path.join(HERE, "hooks"))
68
114
  import tezgah_task as tt # noqa: E402
115
+ from tezgah_paths import say # noqa: E402
69
116
 
70
117
 
71
118
  def repo_root(path):
@@ -161,11 +208,13 @@ def allow_source(text):
161
208
  `--allow`/`allow` (`allowed_from:` names which), or an explicit
162
209
  `--any-path`. No list and no `--any-path` is what a writing phase refuses."""
163
210
  allow = tt.allowed_paths(text)
164
- source = tt.frontmatter(text).get("allowed_from") or ""
211
+ fields = tt.frontmatter(text)
212
+ source = fields.get("allowed_from") or ""
213
+ why = (" (why: %s)" % fields["allowed_why"]) if fields.get("allowed_why") else ""
165
214
  if allow:
166
- return allow, ("from tezgah-task %s" % source) if source else "from the plan"
215
+ return allow, (("from tezgah-task %s" % source) + why) if source else "from the plan"
167
216
  if source == ANY_PATH:
168
- return allow, "any path in the repo (--any-path)"
217
+ return allow, "any path in the repo (--any-path)" + why
169
218
  return allow, "any path in the repo (no allowlist)"
170
219
 
171
220
 
@@ -285,9 +334,9 @@ def phase_problems(phase, text, path):
285
334
  Both are read from the plan this CLI is about to move, through the same
286
335
  readers the CI report uses, so the phase the gate then holds a write to is a
287
336
  phase the plan can actually be checked in. They live here, at the phase move,
288
- and not in the gate: the phase has one writer, this command, and the record
289
- is the user's to write - a gate rule would have to re-read the same body on
290
- every write to ask a question whose answer changes only here."""
337
+ and not in the gate: the phase has one writer, this command - a gate rule
338
+ would have to re-read the same body on every write to ask a question whose
339
+ answer changes only here."""
291
340
  out = []
292
341
  if phase in tt.WRITE_PHASES:
293
342
  items = tt.acceptance_gap(text)
@@ -320,6 +369,18 @@ def changed_lines(root, base):
320
369
  return sum(int(n) for n in re.findall(r"(\d+) (?:insertion|deletion)", stat))
321
370
 
322
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
+
323
384
  def verification_steps(text, path):
324
385
  """What the verification phase requires before the plan is done: the plan's
325
386
  own acceptance commands run once on the final tree, unpiped, and ONE review
@@ -347,8 +408,12 @@ def verification_steps(text, path):
347
408
  "no state go out as tool calls in ONE message, one suite run at a time:")
348
409
  for item in items or ["(the plan has no Acceptance items)"]:
349
410
  print(" - %s" % item)
350
- if os.path.isfile(os.path.join(root, "tests", "impacted.py")):
351
- 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))
352
417
  if rounds and review_verdict(text) == "approve":
353
418
  print(" 2. the review approved in round %d at %s: no further round"
354
419
  % (rounds, reviewed))
@@ -356,9 +421,11 @@ def verification_steps(text, path):
356
421
  return
357
422
  if rounds >= REVIEW_ROUNDS:
358
423
  print(" 2. the %d-round budget is spent: `tezgah-task review` refuses a "
359
- "third round. A critical or major finding still confirmed goes to "
360
- "the user (block); a minor or suggestion one is recorded fix-later "
361
- "under the plan's ## State" % REVIEW_ROUNDS)
424
+ "third round. Fix a critical or major finding still confirmed "
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"]))
362
429
  return
363
430
  if rounds:
364
431
  print(" 2. review round 2 (the last): each confirmed critical or major "
@@ -380,8 +447,10 @@ def verification_steps(text, path):
380
447
  "security reviewer" % (REVIEW_ROUNDS, base, SMALL_DIFF,
381
448
  "unknown" if size is None else size))
382
449
  print(" only a confirmed critical or major finding is `changes` and "
383
- "opens round 2; a minor or suggestion one is recorded fix-later "
384
- "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"])
385
454
  print(" 3. record its verdict: `tezgah-task review %s <reviewer> approve|changes`"
386
455
  " (records the reviewed sha);"
387
456
  " `tezgah-task close %s done` refuses without an approve" % (ident, ident))
@@ -406,7 +475,7 @@ def cmd_start(args):
406
475
  if not args:
407
476
  return misuse("start needs a plan id or path")
408
477
  root = repo_root(os.getcwd())
409
- path, problem = plan_path(root, args[0])
478
+ path, problem = open_plan(args[0])
410
479
  phase = None
411
480
  allow = []
412
481
  any_path = False
@@ -443,10 +512,10 @@ def cmd_start(args):
443
512
  return problems([problem])
444
513
  text = read(path)
445
514
  fields = {"phase": phase, "updated": today()}
446
- if allow:
447
- fields.update(allowed_paths=allow, allowed_from="--allow")
448
- elif any_path:
449
- fields.update(allowed_paths=[], allowed_from=ANY_PATH)
515
+ if allow or any_path:
516
+ fields.update(allowed_paths=allow, allowed_from="--allow" if allow else ANY_PATH)
517
+ if tt.frontmatter(text).get("allowed_why"):
518
+ fields["allowed_why"] = "" # the reason belonged to the scope replaced
450
519
  else:
451
520
  # the plan's own scope, as plan-add wrote it (or an earlier --allow)
452
521
  allow = tt.allowed_paths(text)
@@ -489,6 +558,8 @@ def cmd_phase(args):
489
558
  fields = {"phase": phase, "updated": today()}
490
559
  if any_path:
491
560
  fields.update(allowed_paths=[], allowed_from=ANY_PATH)
561
+ if tt.frontmatter(text).get("allowed_why"):
562
+ fields["allowed_why"] = ""
492
563
  granted = (any_path or tt.frontmatter(text).get("allowed_from")
493
564
  == ANY_PATH)
494
565
  bad = (scope_problem(phase, task["allowed_paths"], granted)
@@ -503,6 +574,13 @@ def cmd_phase(args):
503
574
 
504
575
 
505
576
  def cmd_allow(args):
577
+ # the scope is the session's to widen, so the reason is the price: a widening
578
+ # nobody can read back later is the unbounded task this record exists to stop
579
+ why = ""
580
+ if "--why" in args:
581
+ i = args.index("--why")
582
+ why = " ".join((args[i + 1:i + 2] or [""])[0].split())
583
+ args = args[:i] + args[i + 2:]
506
584
  any_path = ANY_PATH in args
507
585
  args = [a for a in args if a != ANY_PATH]
508
586
  for arg in args:
@@ -510,6 +588,8 @@ def cmd_allow(args):
510
588
  return misuse("allow takes globs, not %r" % arg)
511
589
  if bool(args) == any_path:
512
590
  return misuse("allow takes globs, or --any-path alone to lift the scope")
591
+ if not why:
592
+ return misuse("allow takes --why \"<what in the plan reaches these paths>\"")
513
593
  task = active_here(repo_root(os.getcwd()))
514
594
  if not task:
515
595
  return problems(["no active task to widen: start one with "
@@ -519,7 +599,8 @@ def cmd_allow(args):
519
599
  # the way back out of a scope that turned out to be too narrow. A flag and
520
600
  # not an empty call, so lifting the scope is always said out loud.
521
601
  bad = write_fields(task["path"], allowed_paths=args, updated=today(),
522
- allowed_from=ANY_PATH if any_path else "allow")
602
+ allowed_from=ANY_PATH if any_path else "allow",
603
+ allowed_why=why)
523
604
  if bad:
524
605
  return problems(bad)
525
606
  return show(task["path"])
@@ -538,6 +619,70 @@ def cmd_stop(args):
538
619
  return show(task["path"])
539
620
 
540
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
+
541
686
  def review_verdict(text):
542
687
  """The verdict of the plan's `review: <reviewer> <verdict> <date>` line, or
543
688
  None when it has none or names a verdict this CLI does not write."""
@@ -546,19 +691,64 @@ def review_verdict(text):
546
691
 
547
692
 
548
693
  def budget_spent(path, ident):
549
- """The refusal once REVIEW_ROUNDS rounds are recorded: what is left belongs
550
- to the user, never to a third round."""
551
- return ("%s has spent its %d-round review budget: never a third round. A "
552
- "critical or major finding still confirmed goes to the user, who "
553
- "decides (block); a minor or suggestion one is recorded fix-later "
554
- "under the plan's ## State; `tezgah-task close %s discarded` needs no "
555
- "review" % (path, REVIEW_ROUNDS, ident))
694
+ """The refusal once REVIEW_ROUNDS rounds are recorded: never a third round,
695
+ and never a hand-off - the session settles what is left itself."""
696
+ return ("%s has spent its %d-round review budget: never a third round. Fix a "
697
+ "critical or major finding still confirmed yourself and prove it with "
698
+ "a test, then `tezgah-task close %s done --resolved \"<the fix and the "
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)]
556
746
 
557
747
 
558
748
  def cmd_review(args):
559
749
  if len(args) != 3 or args[2] not in VERDICTS:
560
750
  return misuse("review takes <NNN|path> <reviewer> %s" % "|".join(VERDICTS))
561
- path, problem = plan_path(repo_root(os.getcwd()), args[0])
751
+ path, problem = open_plan(args[0])
562
752
  if problem:
563
753
  return problems([problem])
564
754
  fields = tt.frontmatter(read(path))
@@ -568,6 +758,17 @@ def cmd_review(args):
568
758
  # the project's HEAD, never the private `.tezgah` repository's: the next
569
759
  # round's delta is `git diff <this>..HEAD` over the project
570
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)
571
772
  sha = (git_out(root, "rev-parse", "HEAD") or "")[:12]
572
773
  record = {"review": "%s %s %s" % (args[1], args[2], today()),
573
774
  "review_round": str(int(prior) + 1 if prior.isdigit() else 1),
@@ -583,8 +784,15 @@ def cmd_review(args):
583
784
 
584
785
 
585
786
  def cmd_close(args):
787
+ resolved = ""
788
+ if "--resolved" in args:
789
+ i = args.index("--resolved")
790
+ resolved = " ".join((args[i + 1:i + 2] or [""])[0].split())
791
+ args = args[:i] + args[i + 2:]
792
+ if not resolved:
793
+ return misuse("--resolved takes the fix and the test that proves it")
586
794
  if len(args) != 2 or args[1] not in ("done", "discarded"):
587
- return misuse("close takes <NNN|path> done|discarded")
795
+ return misuse("close takes <NNN|path> done|discarded [--resolved TEXT]")
588
796
  root = repo_root(os.getcwd())
589
797
  path, problem = plan_path(root, args[0])
590
798
  if problem:
@@ -593,18 +801,26 @@ def cmd_close(args):
593
801
  return problems(["%s is not under %s" % (path, open_dir(root))])
594
802
  text = read(path)
595
803
  ident = os.path.basename(path).split("-")[0]
804
+ # closing the active task deactivates it in the same write: the plan leaves
805
+ # open/ anyway, and a separate `stop` first was one more step for nobody
806
+ fields = {"status": args[1], "updated": today()}
596
807
  if tt.frontmatter(text).get("phase"):
597
- return problems(["%s is the active task: the user stops it first "
598
- "(`tezgah-task stop`)" % path])
808
+ fields["phase"] = ""
599
809
  if args[1] == "done" and review_verdict(text) != "approve":
600
810
  prior = tt.frontmatter(text).get("review_round") or ""
601
- if prior.isdigit() and int(prior) >= REVIEW_ROUNDS:
811
+ spent = prior.isdigit() and int(prior) >= REVIEW_ROUNDS
812
+ if spent and resolved:
813
+ # the budget is spent and the session fixed what was left: the fix
814
+ # and its proof go on the record a later reader rejects it from
815
+ fields["resolved"] = "%s %s" % (resolved, today())
816
+ elif spent:
602
817
  return problems([budget_spent(path, ident)])
603
- return problems(["%s records no approving review: a fresh reviewer reads "
604
- "the diff, then `tezgah-task review %s <reviewer> approve`"
605
- % (path, ident)])
818
+ else:
819
+ return problems(["%s records no approving review: a fresh reviewer reads "
820
+ "the diff, then `tezgah-task review %s <reviewer> approve`"
821
+ % (path, ident)])
606
822
  target = os.path.join(root, ".tezgah", "plans", "done", os.path.basename(path))
607
- bad = write_fields(path, status=args[1], updated=today())
823
+ bad = write_fields(path, **fields)
608
824
  if bad:
609
825
  return problems(bad)
610
826
  try:
@@ -613,9 +829,128 @@ def cmd_close(args):
613
829
  except OSError as exc:
614
830
  return problems(["cannot move %s: %s" % (path, exc)])
615
831
  print("moved %s -> %s" % (path, target))
832
+ for line in prune_worktrees(root):
833
+ print(line)
616
834
  return 0
617
835
 
618
836
 
837
+ # Ignored output a slice regenerates on its own. Any other ignored path - an
838
+ # `.env`, a local database, a dataset - may be the only copy, so it keeps the
839
+ # worktree; a list rather than `--ignored` alone because every checkout that ran
840
+ # a test holds a cache.
841
+ REGENERABLE = frozenset(("__pycache__", ".pytest_cache", ".ruff_cache",
842
+ ".mypy_cache", "node_modules", ".venv", "venv",
843
+ ".DS_Store"))
844
+
845
+
846
+ def _landed(root, branch, base):
847
+ """True when `branch`'s work is in `base`: an ancestor of it, or - for a
848
+ squash or rebase merge, which leaves no ancestry - a branch whose merge into
849
+ `base` would change nothing. `merge-tree --write-tree` needs git 2.38; an
850
+ older git answers None, which reads as not landed and keeps the worktree."""
851
+ if git_out(root, "merge-base", "--is-ancestor", branch, base) is not None:
852
+ return True
853
+ # a branch whose commits cancel out (X then revert X) merges to base's tree
854
+ # too, while its commits are in base nowhere: it needs a net change to count
855
+ fork = git_out(root, "merge-base", base, branch)
856
+ if not fork or git_out(root, "diff", "--quiet", fork, branch) is not None:
857
+ return False
858
+ merged = (git_out(root, "merge-tree", "--write-tree", base, branch) or "").split("\n")[0]
859
+ return bool(merged) and merged == git_out(root, "rev-parse", base + "^{tree}")
860
+
861
+
862
+ def _keeps(root, path, branch, bases, agents):
863
+ """Why the linked worktree `path` must stay, or None when its work is all in
864
+ `bases` and it holds nothing else. Every question git cannot answer keeps it."""
865
+ if not branch:
866
+ return "detached HEAD"
867
+ # -unormal: a `status.showUntrackedFiles=no` config would hide both lists
868
+ if git_out(path, "status", "--porcelain", "-unormal") != "":
869
+ return "uncommitted changes"
870
+ for line in (git_out(path, "status", "--porcelain", "-unormal", "--ignored") or "").splitlines():
871
+ rel = line[3:].strip('"').rstrip("/")
872
+ if rel != ".tezgah" and not REGENERABLE & set(rel.split("/")):
873
+ return "ignored file %s may be the only copy" % rel
874
+ ws = os.path.join(path, ".tezgah")
875
+ if os.path.isdir(ws):
876
+ if set(os.listdir(ws)) - {".git"}:
877
+ return "its .tezgah holds data"
878
+ if os.path.isdir(os.path.join(ws, ".git")) and git_out(ws, "rev-parse", "-q", "--verify", "HEAD"):
879
+ return "its .tezgah has commits"
880
+ if not any(_landed(root, branch, b) for b in bases):
881
+ return "%s is not merged into %s" % (branch, " or ".join(bases))
882
+ log = git_out(root, "reflog", "show", "--format=%gs", "refs/heads/" + branch) or ""
883
+ if not any(entry.startswith(("commit:", "commit (amend):", "commit (initial):"))
884
+ for entry in log.splitlines()):
885
+ # a branch nobody committed on is a slice that has not started - even
886
+ # one fast-forwarded to main, or one that only cherry-picked or merged
887
+ # main in - not one whose work landed
888
+ return "%s has no commit of its own yet" % branch
889
+ busy = [a.get("state") for a in agents if a.get("state") != "done"]
890
+ if busy:
891
+ return "an agent there is %s" % busy[0]
892
+ return None
893
+
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
+
904
+ def prune_worktrees(root):
905
+ """Remove every linked worktree of the repository whose branch has landed and
906
+ which holds nothing else, so a closed plan leaves no checkout behind; one line
907
+ per linked worktree, removed or kept with the reason. The main checkout and
908
+ the one `close` runs in always stay. Under Orca the removal is Orca's (its
909
+ card, terminals and merged branch go with it), else `git worktree remove`
910
+ and a safe `git branch -d`. When Orca is installed but does not answer, its
911
+ agents cannot be checked, so every worktree stays."""
912
+ import tezgah_orca
913
+ from tezgah_paths import worktrees
914
+ found = worktrees(root)
915
+ here = os.path.realpath(root)
916
+ linked = [p for p in found[1:] if p != here]
917
+ if not linked:
918
+ return []
919
+ ps = tezgah_orca.run(["worktree", "ps", "--limit", "1000"])
920
+ rows = ps.get("worktrees") if isinstance(ps, dict) else None
921
+ if not isinstance(rows, list) and tezgah_orca.cli():
922
+ # down, or an answer in a shape this reader does not know: either way
923
+ # its agents cannot be checked
924
+ return ["worktree kept: %s (Orca did not answer, so its agents cannot be "
925
+ "checked; run close again once `orca worktree ps` answers)" % p
926
+ for p in linked]
927
+ orca = {os.path.realpath(r.get("path") or ""): r for r in rows or []
928
+ if isinstance(r, dict)}
929
+ bases = landing_bases(root)
930
+ out = []
931
+ for path in linked:
932
+ branch = git_out(path, "symbolic-ref", "-q", "--short", "HEAD")
933
+ try:
934
+ why = _keeps(root, path, branch, bases,
935
+ orca.get(path, {}).get("agents") or [])
936
+ except OSError as exc:
937
+ why = "cannot read it: %s" % exc
938
+ if why is None:
939
+ if path in orca:
940
+ gone = tezgah_orca.run(["worktree", "rm", "--worktree", "path:" + path],
941
+ timeout=60) is not None
942
+ how = "orca worktree rm --worktree path:%s" % path
943
+ else:
944
+ gone = git_out(root, "worktree", "remove", path) is not None
945
+ how = "git worktree remove %s" % path
946
+ if gone:
947
+ git_out(root, "branch", "-d", branch)
948
+ why = None if gone else "`%s` failed; run it and report its error" % how
949
+ out.append("worktree %s: %s (%s)" % ("kept" if why else "removed", path,
950
+ why or branch))
951
+ return out
952
+
953
+
619
954
  def cmd_status(args):
620
955
  as_json = "--json" in args
621
956
  rest = [a for a in args if a != "--json"]
@@ -673,6 +1008,390 @@ def cmd_status(args):
673
1008
  return 1 if bad else 0
674
1009
 
675
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
+
676
1395
  def main(argv):
677
1396
  if len(argv) < 2:
678
1397
  print(__doc__, file=sys.stderr)
@@ -692,8 +1411,14 @@ def main(argv):
692
1411
  return cmd_review(rest)
693
1412
  if cmd == "close":
694
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)
695
1420
  return misuse("unknown command %r (start, phase, allow, stop, status, review, "
696
- "close)" % cmd)
1421
+ "close, tick, note, slice)" % cmd)
697
1422
 
698
1423
 
699
1424
  if __name__ == "__main__":