@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.
- package/CHANGELOG.md +80 -1
- package/MANIFEST +5 -0
- package/README.md +6 -3
- package/bin/tezgah-docs +18 -16
- package/bin/tezgah-doctor +152 -0
- package/bin/tezgah-research +234 -10
- package/bin/tezgah-route +48 -1
- package/bin/tezgah-setup +26 -13
- package/bin/tezgah-task +589 -17
- package/bin/tezgah-taste +48 -10
- package/docs/architecture.md +7 -4
- package/docs/clarity-baseline.json +2 -2
- package/docs/contract.md +108 -24
- package/docs/evidence.md +201 -28
- package/docs/feature-audit.md +1 -1
- package/docs/gate.md +60 -22
- package/docs/glossary.md +4 -4
- package/docs/hosts.md +8 -8
- package/docs/index.json +11 -5
- package/docs/judge.md +9 -3
- package/docs/models.md +35 -2
- package/docs/operations.md +103 -3
- package/docs/research.md +346 -13
- package/docs/skills.md +1 -1
- package/docs/status-line.md +5 -5
- package/docs/testing.md +2 -2
- package/docs/vision.md +12 -4
- package/hooks/hooks.json +2 -2
- package/hooks/lang/en.json +323 -0
- package/hooks/lang/tr.json +344 -0
- package/hooks/projects-posttooluse.py +24 -5
- package/hooks/projects-pretooluse.py +9 -4
- package/hooks/projects-stop.py +28 -16
- package/hooks/tezgah_agents.py +29 -10
- package/hooks/tezgah_clarity.py +7 -3
- package/hooks/tezgah_context.py +425 -406
- package/hooks/tezgah_gate.py +583 -33
- package/hooks/tezgah_integrity.py +1480 -197
- package/hooks/tezgah_models.py +337 -15
- package/hooks/tezgah_paths.py +103 -10
- package/hooks/tezgah_policy.py +258 -126
- package/hooks/tezgah_rank.py +29 -27
- package/hooks/tezgah_replay.py +200 -30
- package/hooks/tezgah_research.py +1294 -9
- package/hooks/tezgah_stopspec.py +7 -0
- package/hooks/tezgah_store.py +74 -27
- package/hooks/tezgah_task.py +915 -6
- package/hosts/codex/hook.py +9 -10
- package/hosts/cursor/hook.py +21 -14
- package/hosts/dsh/hooks.json +2 -2
- package/hosts/omp/hook.py +44 -23
- package/hosts/omp/tezgah-hook.ts.in +93 -7
- package/hosts/opencode/plugins/tezgah.js +102 -2
- package/output-styles/tezgah.md +76 -42
- package/package.json +1 -1
- package/skills/i-have-adhd/EVALS.md +22 -2
- package/skills/i-have-adhd/SKILL.md +12 -5
- package/skills/plan-add/SKILL.md +5 -3
- package/skills/plan-sync/SKILL.md +5 -3
- package/skills/ponytail/EVALS.md +21 -2
- package/skills/ponytail/SKILL.md +4 -4
- package/skills/research/SKILL.md +89 -1
- package/skills/tezgah-contract/EVALS.md +52 -7
- 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
|
-
|
|
362
|
-
|
|
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
|
|
373
|
-
"## State
|
|
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
|
|
396
|
-
"under ## State
|
|
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 =
|
|
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>\"`;
|
|
579
|
-
"under the plan's ## State
|
|
580
|
-
|
|
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 =
|
|
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
|
-
|
|
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__":
|