@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.
- package/CHANGELOG.md +126 -1
- package/MANIFEST +11 -0
- package/README.md +6 -3
- package/bin/consult +13 -10
- package/bin/tezgah-docs +18 -16
- package/bin/tezgah-doctor +152 -0
- package/bin/tezgah-research +286 -12
- package/bin/tezgah-route +48 -1
- package/bin/tezgah-setup +27 -14
- package/bin/tezgah-task +771 -46
- package/bin/tezgah-taste +48 -10
- package/docs/architecture.md +7 -4
- package/docs/citations-baseline.json +1 -1
- package/docs/clarity-baseline.json +5 -5
- package/docs/contract.md +112 -25
- package/docs/evidence.md +201 -28
- package/docs/feature-audit.md +25 -4
- package/docs/gate.md +70 -33
- package/docs/glossary.md +4 -4
- package/docs/hosts.md +8 -8
- package/docs/index.json +30 -10
- package/docs/judge.md +9 -3
- package/docs/models.md +35 -2
- package/docs/operations.md +103 -3
- package/docs/research.md +392 -13
- package/docs/skills.md +24 -6
- 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 -9
- package/hooks/tezgah_clarity.py +7 -3
- package/hooks/tezgah_components.py +2 -1
- package/hooks/tezgah_context.py +460 -390
- package/hooks/tezgah_gate.py +640 -104
- 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 +286 -130
- package/hooks/tezgah_product.py +107 -0
- package/hooks/tezgah_rank.py +29 -27
- package/hooks/tezgah_replay.py +200 -30
- package/hooks/tezgah_research.py +1297 -12
- package/hooks/tezgah_skill_pick.py +58 -17
- package/hooks/tezgah_stopspec.py +7 -0
- package/hooks/tezgah_store.py +74 -27
- package/hooks/tezgah_task.py +917 -8
- 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 +119 -39
- package/output-styles/tezgah.md +76 -42
- package/package.json +1 -1
- package/skills/analyze-app/SKILL.md +57 -0
- package/skills/design-contract/EVALS.md +24 -1
- package/skills/design-contract/SKILL.md +17 -1
- package/skills/design-contract/data-views.md +86 -0
- package/skills/feature-audit/SKILL.md +32 -3
- package/skills/i-have-adhd/EVALS.md +22 -2
- package/skills/i-have-adhd/SKILL.md +12 -5
- package/skills/plan-add/EVALS.md +8 -8
- package/skills/plan-add/SKILL.md +22 -21
- package/skills/plan-sync/EVALS.md +2 -2
- package/skills/plan-sync/SKILL.md +14 -8
- package/skills/pm-frameworks/SKILL.md +6 -0
- package/skills/ponytail/EVALS.md +21 -2
- package/skills/ponytail/SKILL.md +4 -4
- package/skills/product-analysis/EVALS.md +23 -1
- package/skills/product-analysis/SKILL.md +50 -11
- package/skills/product-flow/EVALS.md +81 -0
- package/skills/product-flow/SKILL.md +91 -0
- package/skills/research/SKILL.md +99 -2
- package/skills/tezgah-contract/EVALS.md +52 -7
- 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
|
|
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
|
|
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.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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.
|
|
360
|
-
"
|
|
361
|
-
"
|
|
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
|
|
384
|
-
"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"])
|
|
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 =
|
|
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
|
-
|
|
449
|
-
|
|
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:
|
|
550
|
-
|
|
551
|
-
return ("%s has spent its %d-round review budget: never a third round.
|
|
552
|
-
"critical or major finding still confirmed
|
|
553
|
-
"
|
|
554
|
-
"
|
|
555
|
-
"
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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,
|
|
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__":
|