@r1z4x/tezgah 1.6.0 → 1.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +140 -1
- package/MANIFEST +7 -0
- package/README.md +6 -3
- package/bin/tezgah-decisions +6 -14
- package/bin/tezgah-design +21 -5
- package/bin/tezgah-docs +18 -16
- package/bin/tezgah-doctor +152 -0
- package/bin/tezgah-lessons +4 -1
- package/bin/tezgah-research +249 -17
- package/bin/tezgah-route +70 -8
- package/bin/tezgah-setup +26 -13
- package/bin/tezgah-task +877 -60
- package/bin/tezgah-taste +56 -15
- package/docs/architecture.md +28 -11
- package/docs/clarity-baseline.json +2 -2
- package/docs/contract.md +108 -24
- package/docs/environment.md +3 -1
- package/docs/evidence.md +209 -28
- package/docs/feature-audit.md +1 -1
- package/docs/gate.md +78 -23
- 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 +75 -14
- package/docs/operations.md +133 -3
- package/docs/research.md +349 -15
- package/docs/skills.md +1 -1
- package/docs/status-line.md +5 -5
- package/docs/testing.md +20 -13
- 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-auto-init.py +29 -1
- 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 +53 -17
- package/hooks/tezgah_clarity.py +7 -3
- package/hooks/tezgah_context.py +489 -438
- package/hooks/tezgah_gate.py +996 -80
- package/hooks/tezgah_integrity.py +1547 -197
- package/hooks/tezgah_models.py +427 -45
- package/hooks/tezgah_paths.py +147 -16
- package/hooks/tezgah_policy.py +261 -129
- package/hooks/tezgah_rank.py +29 -27
- package/hooks/tezgah_replay.py +200 -30
- package/hooks/tezgah_research.py +1303 -14
- package/hooks/tezgah_stopspec.py +7 -0
- package/hooks/tezgah_store.py +74 -27
- package/hooks/tezgah_task.py +1201 -40
- package/hooks/tezgah_taste.py +8 -4
- package/hosts/codex/hook.py +32 -13
- package/hosts/cursor/hook.py +32 -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 +273 -6
- 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 +27 -11
- package/skills/plan-add/render_table.py +8 -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 +154 -59
package/bin/tezgah-task
CHANGED
|
@@ -8,15 +8,39 @@
|
|
|
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 spike <NNN|path> "<answer>" record the spike's answer (`spike_recorded:`)
|
|
14
|
+
tezgah-task slice open --id ID [--kind agent|job] [--host H] [--agent A] [--brief TEXT|--brief-file F] [--parent P] [--cwd PATH]
|
|
15
|
+
record a delegated slice (a re-open counts one more attempt)
|
|
16
|
+
tezgah-task slice done ID | slice stop ID [--class C] close a slice record
|
|
17
|
+
tezgah-task slice reconcile [--prune] every open or stopped slice with its resume brief; --prune also removes landed worktrees and branches
|
|
18
|
+
tezgah-task slice resume ID the native resume path, else a re-dispatch brief; marks the record resumed
|
|
19
|
+
tezgah-task slice stats [--window DAYS] [--json] counts per status, failure class and host, and the RSI lever candidates
|
|
20
|
+
tezgah-task slice board [--parent P] the open sibling slices: what each owns, its worktree, branch and last commit
|
|
21
|
+
tezgah-task slice merge-plan [--base REF] the done slice branches in merge order, with their forecast conflicts
|
|
11
22
|
|
|
12
23
|
The record is the plan file itself: `phase:` activates it, `allowed_paths:` is
|
|
13
|
-
the scope a write has to stay inside, and
|
|
14
|
-
|
|
24
|
+
the scope a write has to stay inside, and `start` clears every other plan's
|
|
25
|
+
phase - except, when the plan being started is held by a linked worktree on
|
|
26
|
+
one of its `plan/NNN-...` branches (its declared `branch:` or a slice's),
|
|
27
|
+
another plan held the same way
|
|
28
|
+
whose allowlist shares no prefix with it: that plan keeps its phase, the gate
|
|
29
|
+
holds a write in each worktree to that worktree's plan
|
|
30
|
+
(`tezgah_task.branch_task`), and the main checkout reads the first phased plan
|
|
31
|
+
no worktree holds, or no task when every one is held. `phase`, `allow` and `stop` run inside such a worktree act on
|
|
32
|
+
its plan, and `allow` refuses a scope a held plan's allowlist could share; the
|
|
33
|
+
body writers below and `close` run from any worktree too. The orchestrating
|
|
34
|
+
session runs every command
|
|
15
35
|
here itself as the work moves - the lifecycle is never handed back to the user -
|
|
16
36
|
and this CLI is the record's only writer: the gate refuses a write to the plan
|
|
17
37
|
file, because the checks below are made here and a retyped frontmatter would
|
|
18
38
|
skip them. `phase implementation` (or `verification`) is what lets a write
|
|
19
39
|
through, so the phase changes when the work reaches that point and not before.
|
|
40
|
+
`tick`, `note` and `spike` are the record's body writers for the same reason:
|
|
41
|
+
they write an Acceptance box, the State/Next text or the spike's answer on any
|
|
42
|
+
open plan, active or not, keep the rest of the frontmatter but `updated:`, and
|
|
43
|
+
commit nothing.
|
|
20
44
|
|
|
21
45
|
The allowlist is the plan's own: plan-add writes `allowed_paths:` when it
|
|
22
46
|
creates the plan, and `start` without `--allow` keeps it. A writing phase with
|
|
@@ -30,7 +54,16 @@ project's HEAD it read) and `review_round:`, so the next round reads only
|
|
|
30
54
|
`git diff <reviewed_sha>..HEAD`, and refuses a third round: the budget is two
|
|
31
55
|
rounds per plan; `close NNN done` refuses a plan whose review is missing or not
|
|
32
56
|
`approve`, unless both rounds are spent and `--resolved` records the fix the
|
|
33
|
-
session made and the test that proves it. `close
|
|
57
|
+
session made and the test that proves it. `close NNN done` also refuses, naming
|
|
58
|
+
each by number, an unticked Acceptance item that carries no `UNMET: <reason>`
|
|
59
|
+
(an item the owner accepted as not met); `--resolved` never lifts that, and
|
|
60
|
+
`discarded` is not held to it. `close` on the active task clears its phase in the same write;
|
|
61
|
+
`close NNN done` records `landed:`, the main checkout's HEAD, which a squash
|
|
62
|
+
merge and a branch deletion cannot orphan the way they do `reviewed_sha:` -
|
|
63
|
+
once the reviewed commit has landed there, else it warns and records none. The
|
|
64
|
+
record moves to `done/` under the plans lock every record writer holds, and
|
|
65
|
+
when `.tezgah` is a git repository of its own the move - those two paths and
|
|
66
|
+
nothing else - is committed there as `plan: close NNN <slug>`. Then `close`
|
|
34
67
|
removes each linked worktree whose branch has landed in HEAD, `main`, `master`
|
|
35
68
|
or the remote's default branch (an ancestor of it, or a squash whose merge
|
|
36
69
|
would change nothing) and which holds nothing else (no uncommitted or unknown
|
|
@@ -61,6 +94,31 @@ still HEAD and the tree is still dirty, so committing is the whole way out of it
|
|
|
61
94
|
and the commit clears the refusal by itself; the per-file snapshots
|
|
62
95
|
(bin/tezgah-rollback) are not one state to return to.
|
|
63
96
|
|
|
97
|
+
`slice` keeps the slice journal (hooks/tezgah_task.py): one record per delegated
|
|
98
|
+
slice under `.tezgah/slices/` of the main checkout, opened by the post-tool path
|
|
99
|
+
on a spawn call or by `slice open`, so a parent crash, an omp `/clear`, a quota
|
|
100
|
+
stop or a model or host switch loses no slice. `reconcile` reads each open omp
|
|
101
|
+
record's child transcript and closes the ones that ended, then prints one
|
|
102
|
+
resume brief per open or stopped slice; `--prune` first runs the same worktree
|
|
103
|
+
removal `close` does, deletes (`git branch -D`, a squash merge counts) every
|
|
104
|
+
local branch that has landed and is checked out nowhere - never main, master
|
|
105
|
+
or the current branch - and marks done each record whose branch landed with a
|
|
106
|
+
commit of its own.
|
|
107
|
+
`resume` prints the host's native path first (omp `write agent://<id>` while
|
|
108
|
+
the parent session lives, Claude `SendMessage`), then a brief any host or model
|
|
109
|
+
can re-dispatch: the original brief plus the last commit, the branch and the
|
|
110
|
+
uncommitted files. `stats` flags a `class+host` signature at 3 events, or at 5%
|
|
111
|
+
of a window holding 20 or more slices, and names its lever from a closed set.
|
|
112
|
+
`board` lists the open slices one parent spawned - the session's own, or for a
|
|
113
|
+
subagent its parent's - with the `Owns:` globs of each brief (the lease the
|
|
114
|
+
gate holds siblings to), worktree, branch and last commit. `merge-plan` forecasts
|
|
115
|
+
each done slice branch against `--base` (HEAD) and against the others with `git
|
|
116
|
+
merge-tree`, leaves out a `merge=union` file such as CHANGELOG.md, and orders
|
|
117
|
+
them with a branch touching MANIFEST or the citation baseline last, then fewest
|
|
118
|
+
conflicts first; a branch merge-tree cannot forecast (git before 2.38, no shared
|
|
119
|
+
history) is printed as `not judged` and the exit is 1. `review ... approve`
|
|
120
|
+
is refused while a record whose id or agent is that reviewer is not done.
|
|
121
|
+
|
|
64
122
|
Exit codes: 0 ok, 1 a refusal or a problem - one `FAIL <what>` line per problem,
|
|
65
123
|
with nothing written - 2 misuse.
|
|
66
124
|
"""
|
|
@@ -68,6 +126,7 @@ import glob
|
|
|
68
126
|
import json
|
|
69
127
|
import os
|
|
70
128
|
import re
|
|
129
|
+
import shlex
|
|
71
130
|
import subprocess
|
|
72
131
|
import sys
|
|
73
132
|
import time
|
|
@@ -75,6 +134,7 @@ import time
|
|
|
75
134
|
HERE = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
|
76
135
|
sys.path.insert(0, os.path.join(HERE, "hooks"))
|
|
77
136
|
import tezgah_task as tt # noqa: E402
|
|
137
|
+
from tezgah_paths import project_top, say # noqa: E402
|
|
78
138
|
|
|
79
139
|
|
|
80
140
|
def repo_root(path):
|
|
@@ -84,7 +144,7 @@ def repo_root(path):
|
|
|
84
144
|
out = subprocess.run(["git", "-C", path, "rev-parse", "--show-toplevel"],
|
|
85
145
|
capture_output=True, text=True)
|
|
86
146
|
if out.returncode == 0 and out.stdout.strip():
|
|
87
|
-
return out.stdout.strip()
|
|
147
|
+
return project_top(out.stdout.strip())
|
|
88
148
|
except OSError:
|
|
89
149
|
pass
|
|
90
150
|
return os.path.realpath(path)
|
|
@@ -130,9 +190,12 @@ def plan_path(root, ref):
|
|
|
130
190
|
|
|
131
191
|
|
|
132
192
|
def active_here(root):
|
|
133
|
-
"""The active task
|
|
134
|
-
|
|
135
|
-
|
|
193
|
+
"""The active task, resolved by the module, so the CLI and the gate cannot
|
|
194
|
+
disagree about which plan is active. When every phased plan is held by its
|
|
195
|
+
worktree the gate holds the main checkout to none, and the CLI acts on the
|
|
196
|
+
first held one (`held`): the orchestrating session moves a held plan's
|
|
197
|
+
phase from the main checkout."""
|
|
198
|
+
return tt.active(root, root, held=True)
|
|
136
199
|
|
|
137
200
|
|
|
138
201
|
def problems(items):
|
|
@@ -180,6 +243,15 @@ def allow_source(text):
|
|
|
180
243
|
return allow, "any path in the repo (no allowlist)"
|
|
181
244
|
|
|
182
245
|
|
|
246
|
+
def holder_of(path):
|
|
247
|
+
"""The linked worktree holding the plan at `path` (`tt.plan_holders`: on the
|
|
248
|
+
plan's own branch), else None - the checkout its work, checkpoint and diff
|
|
249
|
+
live in."""
|
|
250
|
+
root = os.path.realpath(os.path.join(os.path.dirname(path), "..", "..", ".."))
|
|
251
|
+
return tt.plan_holders(root).get(
|
|
252
|
+
tt._plan_id(tt.frontmatter(read(path)), os.path.basename(path)))
|
|
253
|
+
|
|
254
|
+
|
|
183
255
|
def checkpoint_state(value, path):
|
|
184
256
|
"""The checkpoint line `show` prints for a plan in `implementation`: the sha
|
|
185
257
|
the work returns to, or the commit the phase waits for - named with the sha it
|
|
@@ -196,7 +268,10 @@ def checkpoint_state(value, path):
|
|
|
196
268
|
state the phase is in."""
|
|
197
269
|
sha = tt.checkpoint_sha(value)
|
|
198
270
|
if sha:
|
|
199
|
-
|
|
271
|
+
# a plan held by its own worktree waits on that checkout's commit, the
|
|
272
|
+
# one the gate reads (tezgah_gate.checkpoint_reason)
|
|
273
|
+
holder = holder_of(path)
|
|
274
|
+
root = holder or repo_root(os.path.dirname(path) or ".")
|
|
200
275
|
head = git_out(root, "rev-parse", "HEAD")
|
|
201
276
|
listed = git_out(root, "status", "--porcelain")
|
|
202
277
|
if not head or head != sha or not listed:
|
|
@@ -207,7 +282,7 @@ def checkpoint_state(value, path):
|
|
|
207
282
|
print(" checkpoint: pending on %s - the tree held uncommitted changes "
|
|
208
283
|
"when the phase began, so this phase's writes are refused until "
|
|
209
284
|
"the commit that moves HEAD off it: %s"
|
|
210
|
-
% (sha, tt.checkpoint_command(path)))
|
|
285
|
+
% (sha, tt.checkpoint_command(path, holder)))
|
|
211
286
|
elif value:
|
|
212
287
|
print(" checkpoint: %s" % value)
|
|
213
288
|
else:
|
|
@@ -331,6 +406,17 @@ def changed_lines(root, base):
|
|
|
331
406
|
return sum(int(n) for n in re.findall(r"(\d+) (?:insertion|deletion)", stat))
|
|
332
407
|
|
|
333
408
|
|
|
409
|
+
# The checks deferred out of the turn to the merge step (AGENTS.md "Checks, by
|
|
410
|
+
# tier"): (the file that marks the repo has it, the command), in run order.
|
|
411
|
+
MERGE_CHECKS = (
|
|
412
|
+
("tests/impacted.py", "python3 tests/impacted.py --all"),
|
|
413
|
+
("bin/tezgah-docs", "python3 bin/tezgah-docs --citations"),
|
|
414
|
+
("bin/tezgah-docs", "python3 bin/tezgah-docs --clarity"),
|
|
415
|
+
("skills/plan-add/render_table.py",
|
|
416
|
+
"python3 skills/plan-add/render_table.py --acceptance --strict"),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
334
420
|
def verification_steps(text, path):
|
|
335
421
|
"""What the verification phase requires before the plan is done: the plan's
|
|
336
422
|
own acceptance commands run once on the final tree, unpiped, and ONE review
|
|
@@ -349,6 +435,11 @@ def verification_steps(text, path):
|
|
|
349
435
|
# the project, not the private repository `.tezgah` may hold
|
|
350
436
|
root = os.path.realpath(os.path.join(os.path.dirname(path), "..", "..", ".."))
|
|
351
437
|
reviewed = fields.get("reviewed_sha") or "(no commit)"
|
|
438
|
+
# a plan held by its own worktree is built on that branch: the review reads
|
|
439
|
+
# that checkout's diff
|
|
440
|
+
holder = holder_of(path)
|
|
441
|
+
where = holder or root
|
|
442
|
+
diff = "git -C %s diff" % shlex.quote(holder) if holder else "git diff"
|
|
352
443
|
prior = fields.get("review_round") or ""
|
|
353
444
|
rounds = int(prior) if prior.isdigit() else 0
|
|
354
445
|
print("verification - required before this plan is done:")
|
|
@@ -358,8 +449,12 @@ def verification_steps(text, path):
|
|
|
358
449
|
"no state go out as tool calls in ONE message, one suite run at a time:")
|
|
359
450
|
for item in items or ["(the plan has no Acceptance items)"]:
|
|
360
451
|
print(" - %s" % item)
|
|
361
|
-
|
|
362
|
-
|
|
452
|
+
# the deferred tier: never inside a turn, run once here at the merge step
|
|
453
|
+
deferred = [cmd for rel, cmd in MERGE_CHECKS
|
|
454
|
+
if os.path.isfile(os.path.join(root, rel))]
|
|
455
|
+
if deferred:
|
|
456
|
+
print(" the merge step's one run of the deferred checks: %s"
|
|
457
|
+
% ", ".join("`%s`" % c for c in deferred))
|
|
363
458
|
if rounds and review_verdict(text) == "approve":
|
|
364
459
|
print(" 2. the review approved in round %d at %s: no further round"
|
|
365
460
|
% (rounds, reviewed))
|
|
@@ -368,32 +463,35 @@ def verification_steps(text, path):
|
|
|
368
463
|
if rounds >= REVIEW_ROUNDS:
|
|
369
464
|
print(" 2. the %d-round budget is spent: `tezgah-task review` refuses a "
|
|
370
465
|
"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
|
|
466
|
+
"yourself, prove it with a test and close with `--resolved`; fix a "
|
|
467
|
+
"minor or suggestion one now, or record it fix-later under the "
|
|
468
|
+
"plan's ## State only when it waits on a user decision (%s)"
|
|
469
|
+
% (REVIEW_ROUNDS, say()["decision"]))
|
|
374
470
|
return
|
|
375
471
|
if rounds:
|
|
376
472
|
print(" 2. review round 2 (the last): each confirmed critical or major "
|
|
377
473
|
"finding is fixed with a regression test that failed first; one "
|
|
378
|
-
"fresh tezgah-reviewer reads only the delta
|
|
474
|
+
"fresh tezgah-reviewer reads only the delta `%s %s..HEAD`, the "
|
|
379
475
|
"round-1 hunks and direct callers of the symbols it touches, and the "
|
|
380
476
|
"test output above, and marks every round-1 finding closed or open; "
|
|
381
|
-
"it does not re-run the suite" % reviewed)
|
|
477
|
+
"it does not re-run the suite" % (diff, reviewed))
|
|
382
478
|
else:
|
|
383
|
-
base = diff_base(
|
|
384
|
-
size = changed_lines(
|
|
479
|
+
base = diff_base(where)
|
|
480
|
+
size = changed_lines(where, base)
|
|
385
481
|
print(" 2. one review of the whole plan, at most %d rounds: spawn fresh "
|
|
386
482
|
"tezgah-reviewer subagents in ONE message, each owning one dimension "
|
|
387
483
|
"- correctness and contract, security, tests and performance - or one "
|
|
388
|
-
"file group of a large diff, over
|
|
484
|
+
"file group of a large diff, over `%s %s..HEAD` and the test "
|
|
389
485
|
"output above, then merge their findings into one verdict. Under %d "
|
|
390
486
|
"changed lines one reviewer reads every dimension (this diff: %s), "
|
|
391
487
|
"unless the diff touches auth, secrets or a gate, which keeps a "
|
|
392
|
-
"security reviewer" % (REVIEW_ROUNDS, base, SMALL_DIFF,
|
|
488
|
+
"security reviewer" % (REVIEW_ROUNDS, diff, base, SMALL_DIFF,
|
|
393
489
|
"unknown" if size is None else size))
|
|
394
490
|
print(" only a confirmed critical or major finding is `changes` and "
|
|
395
|
-
"opens round 2; a minor or suggestion one is
|
|
396
|
-
"under ## State
|
|
491
|
+
"opens round 2; a minor or suggestion one is fixed in the session "
|
|
492
|
+
"without a new round, or recorded fix-later under ## State only "
|
|
493
|
+
"when it waits on a user decision (%s), and the verdict is "
|
|
494
|
+
"`approve`" % say()["decision"])
|
|
397
495
|
print(" 3. record its verdict: `tezgah-task review %s <reviewer> approve|changes`"
|
|
398
496
|
" (records the reviewed sha);"
|
|
399
497
|
" `tezgah-task close %s done` refuses without an approve" % (ident, ident))
|
|
@@ -417,8 +515,8 @@ def scope_problem(phase, allow, any_path):
|
|
|
417
515
|
def cmd_start(args):
|
|
418
516
|
if not args:
|
|
419
517
|
return misuse("start needs a plan id or path")
|
|
420
|
-
root = repo_root(os.getcwd())
|
|
421
|
-
path, problem =
|
|
518
|
+
root = main_root(repo_root(os.getcwd()))
|
|
519
|
+
path, problem = open_plan(args[0])
|
|
422
520
|
phase = None
|
|
423
521
|
allow = []
|
|
424
522
|
any_path = False
|
|
@@ -467,14 +565,25 @@ def cmd_start(args):
|
|
|
467
565
|
or phase_problems(phase, text, path))
|
|
468
566
|
if bad:
|
|
469
567
|
return problems(bad)
|
|
470
|
-
|
|
568
|
+
holders = tt.plan_holders(root)
|
|
569
|
+
# the plan's own worktree, when one holds it: the gate reads the checkpoint
|
|
570
|
+
# against that checkout's HEAD and tree, so it is recorded from there
|
|
571
|
+
here = holders.get(tt._plan_id(tt.frontmatter(text), os.path.basename(path)))
|
|
572
|
+
fields.update(checkpoint_fields(here or root, phase))
|
|
471
573
|
for other in plans(root):
|
|
472
574
|
if other == path:
|
|
473
575
|
continue
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
576
|
+
theirs = read(other)
|
|
577
|
+
front = tt.frontmatter(theirs)
|
|
578
|
+
if front.get("phase"):
|
|
579
|
+
if (here and tt._plan_id(front, os.path.basename(other)) in holders
|
|
580
|
+
and not tt.scopes_overlap(tt.allowed_paths(theirs), allow)):
|
|
581
|
+
# both plans run in their own worktrees, on scopes that share no
|
|
582
|
+
# prefix: the other keeps its phase and the two run at once
|
|
583
|
+
continue
|
|
584
|
+
# otherwise one active task at a time: the plan that had the phase
|
|
585
|
+
# loses it here rather than both being active and the gate reading
|
|
586
|
+
# whichever sorts first
|
|
478
587
|
bad = write_fields(other, phase="", updated=today())
|
|
479
588
|
if bad:
|
|
480
589
|
return problems(bad)
|
|
@@ -506,10 +615,12 @@ def cmd_phase(args):
|
|
|
506
615
|
granted = (any_path or tt.frontmatter(text).get("allowed_from")
|
|
507
616
|
== ANY_PATH)
|
|
508
617
|
bad = (scope_problem(phase, task["allowed_paths"], granted)
|
|
509
|
-
or phase_problems(phase, text, task["path"])
|
|
618
|
+
or phase_problems(phase, text, task["path"])
|
|
619
|
+
or (held_overlap(root, task["id"], []) if any_path else []))
|
|
510
620
|
if bad:
|
|
511
621
|
return problems(bad)
|
|
512
|
-
|
|
622
|
+
where = task.get("worktree") or tt.plan_holders(main_root(root)).get(task["id"])
|
|
623
|
+
fields.update(checkpoint_fields(where or root, phase))
|
|
513
624
|
bad = write_fields(task["path"], **fields)
|
|
514
625
|
if bad:
|
|
515
626
|
return problems(bad)
|
|
@@ -533,10 +644,14 @@ def cmd_allow(args):
|
|
|
533
644
|
return misuse("allow takes globs, or --any-path alone to lift the scope")
|
|
534
645
|
if not why:
|
|
535
646
|
return misuse("allow takes --why \"<what in the plan reaches these paths>\"")
|
|
536
|
-
|
|
647
|
+
root = repo_root(os.getcwd())
|
|
648
|
+
task = active_here(root)
|
|
537
649
|
if not task:
|
|
538
650
|
return problems(["no active task to widen: start one with "
|
|
539
651
|
"`tezgah-task start <id> --phase P --allow GLOB`"])
|
|
652
|
+
bad = held_overlap(root, task["id"], args)
|
|
653
|
+
if bad:
|
|
654
|
+
return problems(bad)
|
|
540
655
|
# --any-path replaces the list with an empty one, which reads as "any path in
|
|
541
656
|
# the repo" - and the gate skips the scope check entirely then, so this is
|
|
542
657
|
# the way back out of a scope that turned out to be too narrow. A flag and
|
|
@@ -562,6 +677,117 @@ def cmd_stop(args):
|
|
|
562
677
|
return show(task["path"])
|
|
563
678
|
|
|
564
679
|
|
|
680
|
+
def main_root(root):
|
|
681
|
+
"""The main checkout of the repository `root` is a checkout of: the one that
|
|
682
|
+
keeps `.tezgah/plans`, so a command run in a plan's worktree finds them."""
|
|
683
|
+
from tezgah_paths import linked_main
|
|
684
|
+
return linked_main(root) or root
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def held_overlap(root, ident, scope):
|
|
688
|
+
"""A problem line per phased plan other than `ident` that a linked worktree
|
|
689
|
+
holds and whose allowlist `scope` could share a file with: two plans running
|
|
690
|
+
at once are kept to disjoint scopes, which `start` checked and a widening
|
|
691
|
+
here must not undo. An empty `scope` is any path, so it meets every one."""
|
|
692
|
+
main = main_root(root)
|
|
693
|
+
holders = tt.plan_holders(main)
|
|
694
|
+
out = []
|
|
695
|
+
for other in plans(main):
|
|
696
|
+
text = read(other)
|
|
697
|
+
front = tt.frontmatter(text)
|
|
698
|
+
oid = tt._plan_id(front, os.path.basename(other))
|
|
699
|
+
if oid == ident or oid not in holders or front.get("phase") not in tt.TASK_PHASES:
|
|
700
|
+
continue
|
|
701
|
+
theirs = tt.allowed_paths(text)
|
|
702
|
+
if tt.scopes_overlap(theirs, scope):
|
|
703
|
+
out.append("scope %s overlaps plan %s's (%s), which runs at once in "
|
|
704
|
+
"worktree %s: plans running side by side share no path "
|
|
705
|
+
"prefix. Narrow the scope, or stop plan %s first"
|
|
706
|
+
% (" ".join(scope) or "any path", oid,
|
|
707
|
+
", ".join(theirs) or "any path", holders[oid], oid))
|
|
708
|
+
return out
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def open_plan(ref):
|
|
712
|
+
"""(path, problem) for a plan under `.tezgah/plans/open/`: `tick` and `note`
|
|
713
|
+
write past the gate, so they take no other file (review of plan 083, F4:
|
|
714
|
+
`note README.md next ...` rewrote any markdown file with a Next section)."""
|
|
715
|
+
root = main_root(repo_root(os.getcwd()))
|
|
716
|
+
path, problem = plan_path(root, ref)
|
|
717
|
+
if problem:
|
|
718
|
+
return None, problem
|
|
719
|
+
if os.path.dirname(os.path.realpath(path)) != os.path.realpath(open_dir(root)):
|
|
720
|
+
return None, "%s is not an open plan under %s" % (path, open_dir(root))
|
|
721
|
+
return path, None
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def cmd_tick(args):
|
|
725
|
+
"""Tick (or `--undo` untick) one Acceptance box: the plan's progress is the
|
|
726
|
+
session's to record, and the gate refuses a direct write to the record."""
|
|
727
|
+
undo = "--undo" in args
|
|
728
|
+
args = [a for a in args if a != "--undo"]
|
|
729
|
+
if len(args) != 2 or not args[1].isdigit():
|
|
730
|
+
return misuse("tick takes <NNN|path> <n> [--undo]")
|
|
731
|
+
path, problem = open_plan(args[0])
|
|
732
|
+
if problem:
|
|
733
|
+
return problems([problem])
|
|
734
|
+
n = int(args[1])
|
|
735
|
+
try:
|
|
736
|
+
# the item's line is found under the lock, where no writer can move it
|
|
737
|
+
item = tt.set_box(path, n, not undo)
|
|
738
|
+
except OSError as exc:
|
|
739
|
+
return problems(["cannot write %s: %s" % (path, exc)])
|
|
740
|
+
if item is None:
|
|
741
|
+
return problems(["%s has %d Acceptance items, not an item %d"
|
|
742
|
+
% (path, len(tt.acceptance_items(read(path))), n)])
|
|
743
|
+
bad = write_fields(path, updated=today())
|
|
744
|
+
if bad:
|
|
745
|
+
return problems(bad)
|
|
746
|
+
print("- [%s] %s" % (" " if undo else "x", item["text"]))
|
|
747
|
+
return 0
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
NOTE_SECTIONS = {"state": "State", "next": "Next"}
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def cmd_note(args):
|
|
754
|
+
"""Replace the plan's `## State` (the newest one) or `## Next` body."""
|
|
755
|
+
if len(args) != 3 or args[1] not in NOTE_SECTIONS or not args[2].strip():
|
|
756
|
+
return misuse('note takes <NNN|path> state|next "<text>"')
|
|
757
|
+
path, problem = open_plan(args[0])
|
|
758
|
+
if problem:
|
|
759
|
+
return problems([problem])
|
|
760
|
+
heading = NOTE_SECTIONS[args[1]]
|
|
761
|
+
try:
|
|
762
|
+
found = tt.set_section(path, heading, args[2])
|
|
763
|
+
except OSError as exc:
|
|
764
|
+
return problems(["cannot write %s: %s" % (path, exc)])
|
|
765
|
+
if not found:
|
|
766
|
+
return problems(["%s has no `## %s` section" % (path, heading)])
|
|
767
|
+
bad = write_fields(path, updated=today())
|
|
768
|
+
if bad:
|
|
769
|
+
return problems(bad)
|
|
770
|
+
print("%s: ## %s updated" % (path, heading))
|
|
771
|
+
return 0
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def cmd_spike(args):
|
|
775
|
+
"""Record the spike's answer (`spike_recorded:`) on an open plan, active or
|
|
776
|
+
not: the gate refuses a direct write to the record, and `phase
|
|
777
|
+
implementation` refuses while the answer is missing (phase_problems)."""
|
|
778
|
+
answer = " ".join(args[1].split()) if len(args) == 2 else ""
|
|
779
|
+
if not answer:
|
|
780
|
+
return misuse('spike takes <NNN|path> "<answer: what the spike found, and where>"')
|
|
781
|
+
path, problem = open_plan(args[0])
|
|
782
|
+
if problem:
|
|
783
|
+
return problems([problem])
|
|
784
|
+
bad = write_fields(path, spike_recorded=answer, updated=today())
|
|
785
|
+
if bad:
|
|
786
|
+
return problems(bad)
|
|
787
|
+
print("%s: spike_recorded: %s" % (path, answer))
|
|
788
|
+
return 0
|
|
789
|
+
|
|
790
|
+
|
|
565
791
|
def review_verdict(text):
|
|
566
792
|
"""The verdict of the plan's `review: <reviewer> <verdict> <date>` line, or
|
|
567
793
|
None when it has none or names a verdict this CLI does not write."""
|
|
@@ -575,15 +801,59 @@ def budget_spent(path, ident):
|
|
|
575
801
|
return ("%s has spent its %d-round review budget: never a third round. Fix a "
|
|
576
802
|
"critical or major finding still confirmed yourself and prove it with "
|
|
577
803
|
"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
|
-
|
|
804
|
+
"test that proves it>\"`; fix a minor or suggestion one now, or record "
|
|
805
|
+
"it fix-later under the plan's ## State only when it waits on a "
|
|
806
|
+
"user decision (%s); `close %s discarded` needs no review"
|
|
807
|
+
% (path, REVIEW_ROUNDS, ident, say()["decision"], ident))
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def unfinished_review(root, reviewer, plan="", since=0):
|
|
811
|
+
"""The refusal of an `approve` while a slice journal record whose id or agent
|
|
812
|
+
is `reviewer` is open, stopped or stale: a review slice that died on a
|
|
813
|
+
provider limit had its approve recorded anyway, and a NameError it would have
|
|
814
|
+
caught reached main (plan 078). A stale review is one whose end nobody saw,
|
|
815
|
+
which reads no more than a stopped one. The omp records are reconciled with
|
|
816
|
+
their transcripts first (`tt.pending`), so a review that finished reads as
|
|
817
|
+
done. A record that names the plan active when it opened counts only for
|
|
818
|
+
that `plan`; one that names none counts when it changed at or after `since`
|
|
819
|
+
(epoch seconds; the plan's checkpoint commit). A `resumed` record has handed
|
|
820
|
+
its work to the re-dispatch, whose own record is the one read. A record
|
|
821
|
+
spawned as `reviewer` under its own id (`Reviewer-<digest6>`,
|
|
822
|
+
`tt.open_slice`) counts too. Under TEZGAH_SESSION only this session's
|
|
823
|
+
records and unowned ones count, by id, agent or name alike: another
|
|
824
|
+
session's review is not this approve's. The refusal names the exact id,
|
|
825
|
+
which `slice resume` and `slice done` take from any session."""
|
|
826
|
+
from tezgah_paths import off
|
|
827
|
+
if off("slices-off"):
|
|
828
|
+
return []
|
|
829
|
+
tt.pending(root)
|
|
830
|
+
session = os.environ.get("TEZGAH_SESSION")
|
|
831
|
+
|
|
832
|
+
def named(rec):
|
|
833
|
+
return reviewer in (rec.get("id"), rec.get("agent"), rec.get("name")) and (
|
|
834
|
+
not session or rec.get("parent") in (session, "", None))
|
|
835
|
+
|
|
836
|
+
def ours(rec):
|
|
837
|
+
if rec.get("plan"):
|
|
838
|
+
return rec["plan"] == plan
|
|
839
|
+
return (_stamp(rec.get("updated")) or 0) >= since
|
|
840
|
+
|
|
841
|
+
return ["review slice %s is %s%s: let it finish, or resume it with `tezgah-task "
|
|
842
|
+
"slice resume %s` and wait for that run; if the review was finished "
|
|
843
|
+
"another way, record it with `tezgah-task slice done %s` - an approve "
|
|
844
|
+
"from a review that did not finish reads nothing"
|
|
845
|
+
% (rec["id"], rec.get("status"),
|
|
846
|
+
(" (%s)" % rec["failure"]) if rec.get("failure") else "",
|
|
847
|
+
rec["id"], rec["id"])
|
|
848
|
+
for rec in tt.slices(root)
|
|
849
|
+
if rec.get("status") in ("open", "stopped", "stale")
|
|
850
|
+
and named(rec) and ours(rec)]
|
|
581
851
|
|
|
582
852
|
|
|
583
853
|
def cmd_review(args):
|
|
584
854
|
if len(args) != 3 or args[2] not in VERDICTS:
|
|
585
855
|
return misuse("review takes <NNN|path> <reviewer> %s" % "|".join(VERDICTS))
|
|
586
|
-
path, problem =
|
|
856
|
+
path, problem = open_plan(args[0])
|
|
587
857
|
if problem:
|
|
588
858
|
return problems([problem])
|
|
589
859
|
fields = tt.frontmatter(read(path))
|
|
@@ -593,7 +863,22 @@ def cmd_review(args):
|
|
|
593
863
|
# the project's HEAD, never the private `.tezgah` repository's: the next
|
|
594
864
|
# round's delta is `git diff <this>..HEAD` over the project
|
|
595
865
|
root = os.path.realpath(os.path.join(os.path.dirname(path), "..", "..", ".."))
|
|
596
|
-
|
|
866
|
+
if args[2] == "approve":
|
|
867
|
+
# the plan's checkpoint commit (a sha, or `pending <sha>`) dates the work
|
|
868
|
+
# this review is about; with none, every record counts
|
|
869
|
+
mark = (fields.get("checkpoint") or "").split()
|
|
870
|
+
since = (git_out(root, "log", "-1", "--format=%ct", mark[-1])
|
|
871
|
+
if mark and mark[-1] != tt.CHECKPOINT_PENDING else None)
|
|
872
|
+
ident = fields.get("id") or os.path.basename(path).split("-")[0]
|
|
873
|
+
bad = unfinished_review(root, args[1], ident,
|
|
874
|
+
int(since) if (since or "").isdigit() else 0)
|
|
875
|
+
if bad:
|
|
876
|
+
return problems(bad)
|
|
877
|
+
# a plan held by its own worktree is built on that branch: its HEAD is the
|
|
878
|
+
# one the next round's diff starts from
|
|
879
|
+
ident = fields.get("id") or os.path.basename(path).split("-")[0]
|
|
880
|
+
where = tt.plan_holders(root).get(ident) or root
|
|
881
|
+
sha = (git_out(where, "rev-parse", "HEAD") or "")[:12]
|
|
597
882
|
record = {"review": "%s %s %s" % (args[1], args[2], today()),
|
|
598
883
|
"review_round": str(int(prior) + 1 if prior.isdigit() else 1),
|
|
599
884
|
"updated": today()}
|
|
@@ -617,7 +902,7 @@ def cmd_close(args):
|
|
|
617
902
|
return misuse("--resolved takes the fix and the test that proves it")
|
|
618
903
|
if len(args) != 2 or args[1] not in ("done", "discarded"):
|
|
619
904
|
return misuse("close takes <NNN|path> done|discarded [--resolved TEXT]")
|
|
620
|
-
root = repo_root(os.getcwd())
|
|
905
|
+
root = main_root(repo_root(os.getcwd()))
|
|
621
906
|
path, problem = plan_path(root, args[0])
|
|
622
907
|
if problem:
|
|
623
908
|
return problems([problem])
|
|
@@ -625,6 +910,14 @@ def cmd_close(args):
|
|
|
625
910
|
return problems(["%s is not under %s" % (path, open_dir(root))])
|
|
626
911
|
text = read(path)
|
|
627
912
|
ident = os.path.basename(path).split("-")[0]
|
|
913
|
+
unticked = tt.open_items(text) if args[1] == "done" else []
|
|
914
|
+
if unticked:
|
|
915
|
+
# a review (or --resolved) answers the diff, never the plan's own boxes
|
|
916
|
+
return problems(["%s item %d: %s" % (path, n, item["text"]) for n, item in unticked] + [
|
|
917
|
+
"%s has %d open Acceptance item(s): tick each one met with `tezgah-task "
|
|
918
|
+
"tick %s %d`, end an item the owner accepted as not met with `UNMET: "
|
|
919
|
+
"<reason, who accepted it>`, or `tezgah-task close %s discarded`"
|
|
920
|
+
% (path, len(unticked), ident, unticked[0][0], ident)])
|
|
628
921
|
# closing the active task deactivates it in the same write: the plan leaves
|
|
629
922
|
# open/ anyway, and a separate `stop` first was one more step for nobody
|
|
630
923
|
fields = {"status": args[1], "updated": today()}
|
|
@@ -643,19 +936,74 @@ def cmd_close(args):
|
|
|
643
936
|
return problems(["%s records no approving review: a fresh reviewer reads "
|
|
644
937
|
"the diff, then `tezgah-task review %s <reviewer> approve`"
|
|
645
938
|
% (path, ident)])
|
|
939
|
+
landed = landed_head(root, text, path) if args[1] == "done" else None
|
|
940
|
+
if landed:
|
|
941
|
+
fields["landed"] = landed
|
|
942
|
+
elif args[1] == "done":
|
|
943
|
+
print("warning: %s's reviewed work is not in %s's HEAD yet, so no `landed:` "
|
|
944
|
+
"recorded" % (ident, root))
|
|
646
945
|
target = os.path.join(root, ".tezgah", "plans", "done", os.path.basename(path))
|
|
647
|
-
bad = write_fields(path, **fields)
|
|
648
|
-
if bad:
|
|
649
|
-
return problems(bad)
|
|
650
946
|
try:
|
|
651
|
-
|
|
652
|
-
os.replace(path, target)
|
|
947
|
+
tt.close_record(path, target, **fields)
|
|
653
948
|
except OSError as exc:
|
|
654
|
-
return problems(["cannot
|
|
949
|
+
return problems(["cannot close %s: %s" % (path, exc)])
|
|
655
950
|
print("moved %s -> %s" % (path, target))
|
|
656
|
-
|
|
951
|
+
name = tt.slug(path)
|
|
952
|
+
line, committed = commit_close(root, os.path.basename(path), "plan: close %s %s"
|
|
953
|
+
% (ident, name.partition("-")[2] or name))
|
|
954
|
+
if line:
|
|
657
955
|
print(line)
|
|
658
|
-
|
|
956
|
+
# the checkout this runs in stays, even when it is a landed plan worktree:
|
|
957
|
+
# removing it would pull the session's own cwd from under it
|
|
958
|
+
for line in prune_worktrees(root, keep=repo_root(os.getcwd())):
|
|
959
|
+
print(line)
|
|
960
|
+
return 0 if committed else 1
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def landed_head(root, text, path):
|
|
964
|
+
"""The main checkout's HEAD when the plan's work is in it, else None.
|
|
965
|
+
reviewed_sha names a worktree HEAD that a squash merge and a branch
|
|
966
|
+
deletion leave in no branch, so `landed` names the commit the work is in.
|
|
967
|
+
It is recorded only when the reviewed commit - else the plan's declared
|
|
968
|
+
`branch:`, else `plan/<slug>` - has landed there (`_landed`: an ancestor,
|
|
969
|
+
or a squash whose merge changes nothing). A `close done` before the merge
|
|
970
|
+
would name a commit without the work (review of plan 097, F7)."""
|
|
971
|
+
front = tt.frontmatter(text)
|
|
972
|
+
for ref in (front.get("reviewed_sha"), front.get("branch"), "plan/" + tt.slug(path)):
|
|
973
|
+
if ref and git_out(root, "rev-parse", "-q", "--verify", ref + "^{commit}"):
|
|
974
|
+
return git_out(root, "rev-parse", "HEAD") if _landed(root, ref, "HEAD") else None
|
|
975
|
+
return None
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def commit_close(root, name, message):
|
|
979
|
+
"""Commit the closed record's move - `plans/open/<name>` to
|
|
980
|
+
`plans/done/<name>`, and nothing else the owner staged - in the private
|
|
981
|
+
`.tezgah` repository: (the line to print, committed). No line and True when
|
|
982
|
+
`.tezgah` is no repository of its own, where git would answer for the
|
|
983
|
+
project around it. A failed commit - a lock `ws_git` waited out in vain -
|
|
984
|
+
is False, so close exits 1: the record has moved, its commit is owed."""
|
|
985
|
+
from tezgah_paths import ws_git
|
|
986
|
+
ws = os.path.realpath(os.path.join(root, ".tezgah"))
|
|
987
|
+
top = git_out(ws, "rev-parse", "--show-toplevel")
|
|
988
|
+
if not top or os.path.realpath(top) != ws:
|
|
989
|
+
return None, True
|
|
990
|
+
paths = ["plans/done/" + name]
|
|
991
|
+
if git_out(ws, "ls-files", "--", "plans/open/" + name):
|
|
992
|
+
paths.insert(0, "plans/open/" + name)
|
|
993
|
+
try:
|
|
994
|
+
done = ws_git(root, "add", "--", paths[-1])
|
|
995
|
+
if not done.returncode:
|
|
996
|
+
done = ws_git(root, "commit", "-q", "-m", message, "--", *paths)
|
|
997
|
+
error = ((done.stderr or done.stdout).strip() or "git exited %d" % done.returncode
|
|
998
|
+
if done.returncode else "")
|
|
999
|
+
except OSError as exc:
|
|
1000
|
+
error = str(exc)
|
|
1001
|
+
if error:
|
|
1002
|
+
return ("close not committed in %s: %s\n the record moved; commit it with "
|
|
1003
|
+
"`git -C %s add -- %s && git -C %s commit -m %r -- %s`"
|
|
1004
|
+
% (ws, error, ws, paths[-1], ws, message, " ".join(paths)), False)
|
|
1005
|
+
return ("committed %s in %s at %s" % (" ".join(paths), ws, git_out(
|
|
1006
|
+
ws, "rev-parse", "--short=12", "HEAD")), True)
|
|
659
1007
|
|
|
660
1008
|
|
|
661
1009
|
# Ignored output a slice regenerates on its own. Any other ignored path - an
|
|
@@ -716,18 +1064,42 @@ def _keeps(root, path, branch, bases, agents):
|
|
|
716
1064
|
return None
|
|
717
1065
|
|
|
718
1066
|
|
|
719
|
-
def
|
|
1067
|
+
def landing_bases(root):
|
|
1068
|
+
"""The refs a branch's work counts as landed in, those that exist: HEAD,
|
|
1069
|
+
main, master, and the remote's default branch too - a PR merged on the host
|
|
1070
|
+
lands there first."""
|
|
1071
|
+
return [b for b in ("HEAD", "main", "master", "origin/HEAD", "origin/main",
|
|
1072
|
+
"origin/master")
|
|
1073
|
+
if git_out(root, "rev-parse", "-q", "--verify", b)]
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def drop_branch(root, branch):
|
|
1077
|
+
"""`git branch -D` a branch `_landed` proved is in a landing base - `-d`
|
|
1078
|
+
refuses a squash- or rebase-merged one, whose commits are in no base. None
|
|
1079
|
+
once it is gone, else the line saying it stays and why."""
|
|
1080
|
+
if not git_out(root, "rev-parse", "-q", "--verify", "refs/heads/" + branch):
|
|
1081
|
+
return None
|
|
1082
|
+
done = subprocess.run(["git", "-C", root, "branch", "-D", branch],
|
|
1083
|
+
capture_output=True, text=True)
|
|
1084
|
+
if done.returncode == 0:
|
|
1085
|
+
return None
|
|
1086
|
+
return ("branch kept: %s (`git branch -D %s` refused: %s)"
|
|
1087
|
+
% (branch, branch, ((done.stderr or "").strip().splitlines() or ["?"])[0]))
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def prune_worktrees(root, keep=None):
|
|
720
1091
|
"""Remove every linked worktree of the repository whose branch has landed and
|
|
721
1092
|
which holds nothing else, so a closed plan leaves no checkout behind; one line
|
|
722
1093
|
per linked worktree, removed or kept with the reason. The main checkout and
|
|
723
|
-
the one `close` runs in always stay. Under Orca the
|
|
724
|
-
card, terminals and merged branch go with it), else
|
|
725
|
-
|
|
726
|
-
|
|
1094
|
+
the one `close` runs in (`keep`, else `root`) always stay. Under Orca the
|
|
1095
|
+
removal is Orca's (its card, terminals and merged branch go with it), else
|
|
1096
|
+
`git worktree remove`; the landed branch is then deleted (`drop_branch`), a
|
|
1097
|
+
refusal printed. When Orca is installed but does not answer, its agents
|
|
1098
|
+
cannot be checked, so every worktree stays."""
|
|
727
1099
|
import tezgah_orca
|
|
728
1100
|
from tezgah_paths import worktrees
|
|
729
1101
|
found = worktrees(root)
|
|
730
|
-
here = os.path.realpath(root)
|
|
1102
|
+
here = os.path.realpath(keep or root)
|
|
731
1103
|
linked = [p for p in found[1:] if p != here]
|
|
732
1104
|
if not linked:
|
|
733
1105
|
return []
|
|
@@ -741,10 +1113,7 @@ def prune_worktrees(root):
|
|
|
741
1113
|
for p in linked]
|
|
742
1114
|
orca = {os.path.realpath(r.get("path") or ""): r for r in rows or []
|
|
743
1115
|
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)]
|
|
1116
|
+
bases = landing_bases(root)
|
|
748
1117
|
out = []
|
|
749
1118
|
for path in linked:
|
|
750
1119
|
branch = git_out(path, "symbolic-ref", "-q", "--short", "HEAD")
|
|
@@ -761,11 +1130,24 @@ def prune_worktrees(root):
|
|
|
761
1130
|
else:
|
|
762
1131
|
gone = git_out(root, "worktree", "remove", path) is not None
|
|
763
1132
|
how = "git worktree remove %s" % path
|
|
764
|
-
if gone:
|
|
765
|
-
git_out(root, "branch", "-d", branch)
|
|
766
1133
|
why = None if gone else "`%s` failed; run it and report its error" % how
|
|
767
1134
|
out.append("worktree %s: %s (%s)" % ("kept" if why else "removed", path,
|
|
768
1135
|
why or branch))
|
|
1136
|
+
if why is None:
|
|
1137
|
+
# Orca removes the checkout but git may still list it, and git
|
|
1138
|
+
# refuses to delete a branch a listed worktree holds
|
|
1139
|
+
git_out(root, "worktree", "prune")
|
|
1140
|
+
# _keeps answered None, so the branch landed: a squash merge too
|
|
1141
|
+
kept = drop_branch(root, branch)
|
|
1142
|
+
if kept:
|
|
1143
|
+
out.append(kept)
|
|
1144
|
+
# its work landed and the checkout is gone: a record still open on it
|
|
1145
|
+
# would be offered for resume in a worktree that no longer exists
|
|
1146
|
+
for rec in tt.slices(root):
|
|
1147
|
+
if rec.get("worktree") == path and rec.get("status") in ("open", "stopped", "stale"):
|
|
1148
|
+
tt.close_slice(root, rec["id"], "done")
|
|
1149
|
+
out.append("slice %s done: worktree %s removed, %s landed"
|
|
1150
|
+
% (rec["id"], path, branch))
|
|
769
1151
|
return out
|
|
770
1152
|
|
|
771
1153
|
|
|
@@ -826,6 +1208,399 @@ def cmd_status(args):
|
|
|
826
1208
|
return 1 if bad else 0
|
|
827
1209
|
|
|
828
1210
|
|
|
1211
|
+
# ------------------------------------------------------------------ slice
|
|
1212
|
+
|
|
1213
|
+
# The closed lever set a stats candidate is named from (the research line's
|
|
1214
|
+
# RSI loop): one file or mechanism per failure class, never prose.
|
|
1215
|
+
LEVERS = {"rate_limit": "hooks/tezgah_models.py fallback chain",
|
|
1216
|
+
"quota": "hooks/tezgah_models.py fallback chain",
|
|
1217
|
+
"cut_off": "per-slice deadline", "timeout": "per-slice deadline",
|
|
1218
|
+
"aborted": "wip: commit cadence", "duplicate": "journal background jobs",
|
|
1219
|
+
"error": "none (read the transcript)"}
|
|
1220
|
+
STATS_WINDOW = 14
|
|
1221
|
+
# A signature is a candidate at this many events, or at this share of the slices
|
|
1222
|
+
# in the window once the window holds STATS_SHARE_FLOOR slices: below that, one
|
|
1223
|
+
# event is already 5% and every stop would read as a pattern.
|
|
1224
|
+
STATS_EVENTS = 3
|
|
1225
|
+
STATS_SHARE = 0.05
|
|
1226
|
+
STATS_SHARE_FLOOR = 20
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def parse_options(args, valued, flags=()):
|
|
1230
|
+
"""(options, positionals, problem) for `args`: each `valued` option takes the
|
|
1231
|
+
next argument, each flag stands alone, any other `--x` is a problem."""
|
|
1232
|
+
opts, pos, i = {}, [], 0
|
|
1233
|
+
while i < len(args):
|
|
1234
|
+
arg = args[i]
|
|
1235
|
+
if arg in valued:
|
|
1236
|
+
if i + 1 >= len(args):
|
|
1237
|
+
return None, None, "%s needs a value" % arg
|
|
1238
|
+
opts[arg] = args[i + 1]
|
|
1239
|
+
i += 2
|
|
1240
|
+
elif arg in flags:
|
|
1241
|
+
opts[arg] = True
|
|
1242
|
+
i += 1
|
|
1243
|
+
elif arg.startswith("--"):
|
|
1244
|
+
return None, None, "unknown option %r" % arg
|
|
1245
|
+
else:
|
|
1246
|
+
pos.append(arg)
|
|
1247
|
+
i += 1
|
|
1248
|
+
return opts, pos, None
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def resume_text(root, rec):
|
|
1252
|
+
"""The way back into one record's work: the host's native path first (omp
|
|
1253
|
+
`write agent://<id>` while the parent session lives, Claude `SendMessage`),
|
|
1254
|
+
else a re-dispatch brief that runs on any host or model - the original brief
|
|
1255
|
+
plus where the work stands (last commit, branch, uncommitted files)."""
|
|
1256
|
+
sid = rec["id"]
|
|
1257
|
+
lines = [tt.slice_line(rec)]
|
|
1258
|
+
if rec.get("kind") == "job":
|
|
1259
|
+
lines.append(" command: %s" % (rec.get("brief") or "(none recorded)"))
|
|
1260
|
+
return "\n".join(lines)
|
|
1261
|
+
tree = rec.get("worktree") or ""
|
|
1262
|
+
cwd = rec.get("cwd") or ""
|
|
1263
|
+
if tree and not os.path.isdir(tree):
|
|
1264
|
+
# the checkout was removed: the main checkout's HEAD is not the slice's
|
|
1265
|
+
# last commit, so say what is known instead of naming the wrong one
|
|
1266
|
+
branch = rec.get("branch") or ""
|
|
1267
|
+
sha = git_out(root, "rev-parse", "-q", "--short", "--verify",
|
|
1268
|
+
"refs/heads/" + branch) if branch else None
|
|
1269
|
+
resume = ("Resume: worktree %s no longer exists; branch %s %s. If its work "
|
|
1270
|
+
"landed, close this record with `tezgah-task slice done %s`; else "
|
|
1271
|
+
"re-dispatch in a new worktree%s"
|
|
1272
|
+
% (tree, branch or "(none recorded)",
|
|
1273
|
+
"is at %s" % sha if sha else "no longer exists", sid,
|
|
1274
|
+
" from %s" % branch if sha else ""))
|
|
1275
|
+
else:
|
|
1276
|
+
where = cwd if not tree and cwd and os.path.isdir(cwd) else tree or root
|
|
1277
|
+
last = git_out(where, "log", "-1", "--format=%h %s") or "(none)"
|
|
1278
|
+
branch = (rec.get("branch") or git_out(where, "symbolic-ref", "-q", "--short", "HEAD")
|
|
1279
|
+
or "(detached HEAD)")
|
|
1280
|
+
# the journal's own directory is not the slice's work, ignored or not
|
|
1281
|
+
dirty = [line[3:] for line in (git_out(where, "status", "--porcelain") or "").splitlines()
|
|
1282
|
+
if not line[3:].startswith(".tezgah")]
|
|
1283
|
+
resume = ("Resume: last commit %s on %s; uncommitted: %s; continue from there, "
|
|
1284
|
+
"do not redo committed steps" % (last, branch, ", ".join(dirty) or "none"))
|
|
1285
|
+
# a Claude record named by a 12-hex brief digest addresses no agent: no
|
|
1286
|
+
# SendMessage line for it, only the re-dispatch
|
|
1287
|
+
named = not re.fullmatch(r"[0-9a-f]{12}", sid)
|
|
1288
|
+
native = {"omp": "`write agent://%s` (while omp session %s still runs)"
|
|
1289
|
+
% (sid, rec.get("parent") or "?"),
|
|
1290
|
+
"claude": "`SendMessage` to %s" % sid if named else None}.get(rec.get("host"))
|
|
1291
|
+
if native:
|
|
1292
|
+
lines.append(" native: %s with: %s" % (native, resume))
|
|
1293
|
+
provider = (" on another provider (`tezgah-route` names the tier; the same "
|
|
1294
|
+
"account's limit would stop it again)"
|
|
1295
|
+
if rec.get("failure") in ("quota", "rate_limit") else
|
|
1296
|
+
" on any host or model")
|
|
1297
|
+
lines.append(" %sre-dispatch%s with this brief:" % ("else " if native else "", provider))
|
|
1298
|
+
lines.append(rec.get("brief") or "(no brief recorded)")
|
|
1299
|
+
lines.append(resume)
|
|
1300
|
+
return "\n".join(lines)
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def own_commits(root, rec):
|
|
1304
|
+
"""Does the record's branch carry a commit past the base it was opened on?
|
|
1305
|
+
A branch still at its base is a slice that has not started, not one whose
|
|
1306
|
+
work landed."""
|
|
1307
|
+
base, branch = rec.get("base"), rec.get("branch")
|
|
1308
|
+
if not base or not branch:
|
|
1309
|
+
return False
|
|
1310
|
+
count = git_out(root, "rev-list", "--count", "%s..%s" % (base, branch))
|
|
1311
|
+
return bool(count) and count != "0"
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def prune_branches(root, bases):
|
|
1315
|
+
"""`git branch -D` every local branch whose work landed in `bases` (`_landed`,
|
|
1316
|
+
a squash merge too), that no
|
|
1317
|
+
worktree has checked out, that tracks no upstream (a long-lived branch such
|
|
1318
|
+
as `develop` tracks one) and that carries a commit of its own (its reflog
|
|
1319
|
+
names one, the guard `_keeps` applies to a worktree: a pre-created branch
|
|
1320
|
+
nobody committed on is a slice not yet started) - never main, master or the
|
|
1321
|
+
current branch. One line per branch it deletes or git refuses."""
|
|
1322
|
+
current = git_out(root, "symbolic-ref", "-q", "--short", "HEAD")
|
|
1323
|
+
held = {line[len("branch refs/heads/"):] for line in
|
|
1324
|
+
(git_out(root, "worktree", "list", "--porcelain") or "").splitlines()
|
|
1325
|
+
if line.startswith("branch refs/heads/")}
|
|
1326
|
+
out = []
|
|
1327
|
+
for row in (git_out(root, "for-each-ref", "--format=%(refname:short)\t%(upstream)",
|
|
1328
|
+
"refs/heads") or "").splitlines():
|
|
1329
|
+
branch, _tab, upstream = row.partition("\t")
|
|
1330
|
+
if branch in ("main", "master", current) or branch in held or upstream:
|
|
1331
|
+
continue
|
|
1332
|
+
log = git_out(root, "reflog", "show", "--format=%gs", "refs/heads/" + branch) or ""
|
|
1333
|
+
if not any(entry.startswith(("commit:", "commit (amend):", "commit (initial):"))
|
|
1334
|
+
for entry in log.splitlines()):
|
|
1335
|
+
continue
|
|
1336
|
+
if not any(_landed(root, branch, b) for b in bases):
|
|
1337
|
+
continue
|
|
1338
|
+
out.append(drop_branch(root, branch) or "branch deleted: %s" % branch)
|
|
1339
|
+
return out
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def _stamp(value):
|
|
1343
|
+
"""Seconds since the epoch of an ISO-8601 UTC record time, None when bad."""
|
|
1344
|
+
import calendar
|
|
1345
|
+
try:
|
|
1346
|
+
return calendar.timegm(time.strptime(str(value), "%Y-%m-%dT%H:%M:%SZ"))
|
|
1347
|
+
except ValueError:
|
|
1348
|
+
return None
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def slice_stats(rows, window, now=None):
|
|
1352
|
+
"""Counts per status, failure class and host over the records updated in the
|
|
1353
|
+
last `window` days, and the candidate signatures: `class+host`, one event per
|
|
1354
|
+
record (the class it last stopped with) and `duplicate+host` per job record
|
|
1355
|
+
run more than once, at STATS_EVENTS events or STATS_SHARE of the slices."""
|
|
1356
|
+
from collections import Counter
|
|
1357
|
+
cutoff = (time.time() if now is None else now) - window * 86400
|
|
1358
|
+
picked = [r for r in rows if (_stamp(r.get("updated") or r.get("opened")) or 0) >= cutoff]
|
|
1359
|
+
sigs = Counter()
|
|
1360
|
+
for rec in picked:
|
|
1361
|
+
host = rec.get("host") or "?"
|
|
1362
|
+
if rec.get("failure") in LEVERS:
|
|
1363
|
+
sigs[(rec["failure"], host)] += 1
|
|
1364
|
+
if rec.get("kind") == "job" and int(rec.get("attempts") or 1) > 1:
|
|
1365
|
+
sigs[("duplicate", host)] += 1
|
|
1366
|
+
total = len(picked)
|
|
1367
|
+
candidates = [{"signature": "%s+%s" % key, "events": n,
|
|
1368
|
+
"share": round(n / total, 3), "lever": LEVERS[key[0]]}
|
|
1369
|
+
for key, n in sorted(sigs.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
1370
|
+
if n >= STATS_EVENTS
|
|
1371
|
+
or (total >= STATS_SHARE_FLOOR and n >= STATS_SHARE * total)]
|
|
1372
|
+
return {"window_days": window, "slices": total,
|
|
1373
|
+
"status": dict(Counter(r.get("status") or "?" for r in picked)),
|
|
1374
|
+
"failure": dict(Counter(r["failure"] for r in picked if r.get("failure"))),
|
|
1375
|
+
"host": dict(Counter(r.get("host") or "?" for r in picked)),
|
|
1376
|
+
"candidates": candidates}
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def _counts(counter):
|
|
1380
|
+
return ", ".join("%s %d" % kv for kv in sorted(counter.items())) or "none"
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
# Files every slice appends to and only the integrator resolves (research
|
|
1384
|
+
# orchestrator-flow-audit S6, finding 8): a branch that touches one merges
|
|
1385
|
+
# after every branch that does not.
|
|
1386
|
+
HOTSPOTS = ("MANIFEST", "docs/citations-baseline.json")
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def merge_conflicts(root, ours, theirs):
|
|
1390
|
+
"""The files `git merge-tree` forecasts in conflict when `theirs` merges
|
|
1391
|
+
into `ours`, minus the ones a `merge=union` attribute merges (CHANGELOG.md
|
|
1392
|
+
here); None when git cannot answer (no such ref, git before 2.38)."""
|
|
1393
|
+
try:
|
|
1394
|
+
out = subprocess.run(["git", "-C", root, "merge-tree", "--write-tree",
|
|
1395
|
+
"--name-only", "--no-messages", ours, theirs],
|
|
1396
|
+
capture_output=True, text=True)
|
|
1397
|
+
except OSError:
|
|
1398
|
+
return None
|
|
1399
|
+
if out.returncode not in (0, 1):
|
|
1400
|
+
return None
|
|
1401
|
+
files = sorted(set(out.stdout.split("\n\n")[0].splitlines()[1:]))
|
|
1402
|
+
if not files:
|
|
1403
|
+
return []
|
|
1404
|
+
attrs = git_out(root, "check-attr", "merge", "--", *files) or ""
|
|
1405
|
+
union = {line.rsplit(": merge: ", 1)[0] for line in attrs.splitlines()
|
|
1406
|
+
if line.endswith(": merge: union")}
|
|
1407
|
+
return [f for f in files if f not in union]
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
def merge_plan(root, base):
|
|
1411
|
+
"""(lines, unjudged) for the done slices whose branch carries work `base`
|
|
1412
|
+
lacks. The lines are the order to merge them: a branch touching no
|
|
1413
|
+
HOTSPOTS first, then fewest forecast conflicts with `base`, then fewest
|
|
1414
|
+
siblings it overlaps; each names its conflicting files and the siblings it
|
|
1415
|
+
collides with. `unjudged` names each branch `git merge-tree` could not
|
|
1416
|
+
forecast (git before 2.38, no shared history) instead of dropping it."""
|
|
1417
|
+
branches = {}
|
|
1418
|
+
for rec in tt.slices(root):
|
|
1419
|
+
branch = rec.get("branch")
|
|
1420
|
+
if (rec.get("status") == "done" and branch and branch not in branches
|
|
1421
|
+
and own_commits(root, rec) and not _landed(root, branch, base)):
|
|
1422
|
+
branches[branch] = rec
|
|
1423
|
+
rows, unjudged = [], []
|
|
1424
|
+
for branch, rec in branches.items():
|
|
1425
|
+
mine = merge_conflicts(root, base, branch)
|
|
1426
|
+
if mine is None:
|
|
1427
|
+
unjudged.append("not judged: %s (slice %s): `git merge-tree --write-tree` "
|
|
1428
|
+
"could not forecast it against %s" % (branch, rec["id"], base))
|
|
1429
|
+
continue
|
|
1430
|
+
overlaps = {}
|
|
1431
|
+
for other in branches:
|
|
1432
|
+
files = merge_conflicts(root, other, branch) if other != branch else None
|
|
1433
|
+
if files:
|
|
1434
|
+
overlaps[other] = files
|
|
1435
|
+
touched = (git_out(root, "diff", "--name-only", "%s...%s" % (base, branch))
|
|
1436
|
+
or "").splitlines()
|
|
1437
|
+
hot = any(f in HOTSPOTS for f in touched)
|
|
1438
|
+
rows.append(((hot, len(mine), len(overlaps), branch), rec, mine, overlaps))
|
|
1439
|
+
rows.sort(key=lambda row: row[0])
|
|
1440
|
+
out = []
|
|
1441
|
+
for n, (key, rec, mine, overlaps) in enumerate(rows, 1):
|
|
1442
|
+
line = "%d. %s (slice %s): %d conflict(s) with %s%s" % (
|
|
1443
|
+
n, rec["branch"], rec["id"], len(mine), base,
|
|
1444
|
+
": " + ", ".join(mine) if mine else "")
|
|
1445
|
+
for other, files in sorted(overlaps.items()):
|
|
1446
|
+
line += "; overlaps %s on %s" % (other, ", ".join(files))
|
|
1447
|
+
out.append(line + (" (touches a hotspot: the integrator merges it last)"
|
|
1448
|
+
if key[0] else ""))
|
|
1449
|
+
return out, unjudged
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
def board_parent():
|
|
1453
|
+
"""The parent whose slices `slice board` shows: the session in
|
|
1454
|
+
TEZGAH_SESSION, or - read from a subagent - the parent that spawned it
|
|
1455
|
+
(`tezgah_integrity.spawn_of`), so a slice sees its siblings; None, every
|
|
1456
|
+
open slice, outside a session."""
|
|
1457
|
+
session = os.environ.get("TEZGAH_SESSION")
|
|
1458
|
+
if not session:
|
|
1459
|
+
return None
|
|
1460
|
+
try:
|
|
1461
|
+
from tezgah_integrity import spawn_of
|
|
1462
|
+
spawn = spawn_of(session)
|
|
1463
|
+
except Exception:
|
|
1464
|
+
spawn = None
|
|
1465
|
+
return spawn[0] if spawn else session
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def cmd_slice(args):
|
|
1469
|
+
usage = ("slice takes open|done|stop|reconcile|resume|stats|board|merge-plan "
|
|
1470
|
+
"(open --id ID [--kind agent|job] [--host H] [--agent A] "
|
|
1471
|
+
"[--brief TEXT|--brief-file F] [--parent P] [--cwd PATH]; done ID; stop ID "
|
|
1472
|
+
"[--class C]; reconcile [--prune]; resume ID; stats [--window DAYS] "
|
|
1473
|
+
"[--json]; board [--parent P]; merge-plan [--base REF])")
|
|
1474
|
+
if not args:
|
|
1475
|
+
return misuse(usage)
|
|
1476
|
+
sub, rest = args[0], args[1:]
|
|
1477
|
+
root = repo_root(os.getcwd())
|
|
1478
|
+
if sub == "board":
|
|
1479
|
+
opts, pos, bad = parse_options(rest, ("--parent",))
|
|
1480
|
+
if bad or pos:
|
|
1481
|
+
return misuse(bad or "slice board takes [--parent P] and nothing else")
|
|
1482
|
+
lines = tt.board(root, opts.get("--parent") or board_parent())
|
|
1483
|
+
print("\n".join(lines) if lines else "no open slice")
|
|
1484
|
+
return 0
|
|
1485
|
+
if sub == "merge-plan":
|
|
1486
|
+
opts, pos, bad = parse_options(rest, ("--base",))
|
|
1487
|
+
if bad or pos:
|
|
1488
|
+
return misuse(bad or "slice merge-plan takes [--base REF] and nothing else")
|
|
1489
|
+
base = opts.get("--base", "HEAD")
|
|
1490
|
+
if git_out(root, "rev-parse", "-q", "--verify", base) is None:
|
|
1491
|
+
return problems(["no revision %s in %s" % (base, root)])
|
|
1492
|
+
lines, unjudged = merge_plan(root, base)
|
|
1493
|
+
if lines or not unjudged:
|
|
1494
|
+
print("\n".join(lines) if lines else "no done slice branch to merge into %s" % base)
|
|
1495
|
+
for line in unjudged:
|
|
1496
|
+
print(line)
|
|
1497
|
+
return 1 if unjudged else 0
|
|
1498
|
+
if sub == "open":
|
|
1499
|
+
opts, pos, bad = parse_options(rest, ("--id", "--kind", "--host", "--agent",
|
|
1500
|
+
"--brief", "--brief-file", "--parent",
|
|
1501
|
+
"--cwd"))
|
|
1502
|
+
if bad or pos:
|
|
1503
|
+
return misuse(bad or "slice open takes options only (%r)" % pos[0])
|
|
1504
|
+
if not opts.get("--id"):
|
|
1505
|
+
return misuse("slice open needs --id ID")
|
|
1506
|
+
if "--brief" in opts and "--brief-file" in opts:
|
|
1507
|
+
return misuse("--brief and --brief-file contradict each other")
|
|
1508
|
+
kind, host = opts.get("--kind", "agent"), opts.get("--host", "")
|
|
1509
|
+
if kind not in tt.SLICE_KINDS:
|
|
1510
|
+
return misuse("--kind is one of %s" % ", ".join(tt.SLICE_KINDS))
|
|
1511
|
+
if host and host not in tt.SLICE_HOSTS:
|
|
1512
|
+
return misuse("--host is one of %s" % ", ".join(tt.SLICE_HOSTS))
|
|
1513
|
+
brief = opts.get("--brief", "")
|
|
1514
|
+
if "--brief-file" in opts:
|
|
1515
|
+
try:
|
|
1516
|
+
brief = read(opts["--brief-file"])
|
|
1517
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
1518
|
+
return problems(["cannot read %s: %s" % (opts["--brief-file"], exc)])
|
|
1519
|
+
try:
|
|
1520
|
+
rec = tt.open_slice(root, opts["--id"], kind=kind, host=host,
|
|
1521
|
+
agent=opts.get("--agent", ""), brief=brief,
|
|
1522
|
+
parent=opts.get("--parent", ""),
|
|
1523
|
+
cwd=opts.get("--cwd") or os.getcwd())
|
|
1524
|
+
except OSError as exc:
|
|
1525
|
+
return problems(["cannot write the slice record: %s" % exc])
|
|
1526
|
+
print("slice %s open (%s, attempt %d)%s" % (
|
|
1527
|
+
rec["id"], rec["kind"], rec["attempts"],
|
|
1528
|
+
" worktree %s on %s" % (rec["worktree"], rec["branch"] or "?")
|
|
1529
|
+
if rec["worktree"] else ""))
|
|
1530
|
+
return 0
|
|
1531
|
+
if sub in ("done", "stop"):
|
|
1532
|
+
opts, pos, bad = parse_options(rest, ("--class",) if sub == "stop" else ())
|
|
1533
|
+
if bad or len(pos) != 1:
|
|
1534
|
+
return misuse(bad or "slice %s takes one ID%s" % (
|
|
1535
|
+
sub, " [--class C]" if sub == "stop" else ""))
|
|
1536
|
+
failure = opts.get("--class", "")
|
|
1537
|
+
if failure and failure not in tt.FAILURES:
|
|
1538
|
+
return misuse("--class is one of %s" % ", ".join(tt.FAILURES))
|
|
1539
|
+
status = "done" if sub == "done" else "stopped"
|
|
1540
|
+
try:
|
|
1541
|
+
found = tt.find_slice(root, pos[0], os.environ.get("TEZGAH_SESSION"))
|
|
1542
|
+
rec = found and tt.close_slice(root, found["id"], status, failure)
|
|
1543
|
+
except OSError as exc:
|
|
1544
|
+
return problems(["cannot write the slice record: %s" % exc])
|
|
1545
|
+
if not rec:
|
|
1546
|
+
return problems(["no slice record %s under %s" % (pos[0], tt.slice_dir(root))])
|
|
1547
|
+
print("slice %s %s%s" % (rec["id"], status,
|
|
1548
|
+
" (%s)" % rec["failure"] if rec.get("failure") else ""))
|
|
1549
|
+
return 0
|
|
1550
|
+
if sub == "reconcile":
|
|
1551
|
+
opts, pos, bad = parse_options(rest, (), ("--prune",))
|
|
1552
|
+
if bad or pos:
|
|
1553
|
+
return misuse(bad or "slice reconcile takes --prune and nothing else")
|
|
1554
|
+
if opts.get("--prune"):
|
|
1555
|
+
bases = landing_bases(root)
|
|
1556
|
+
# read before the prune: a removed worktree takes its branch with it
|
|
1557
|
+
landed = [rec for rec in tt.slices(root)
|
|
1558
|
+
if rec.get("status") != "done" and own_commits(root, rec)
|
|
1559
|
+
and any(_landed(root, rec["branch"], b) for b in bases)]
|
|
1560
|
+
for line in prune_worktrees(root) + prune_branches(root, bases):
|
|
1561
|
+
print(line)
|
|
1562
|
+
for rec in landed:
|
|
1563
|
+
tt.close_slice(root, rec["id"], "done")
|
|
1564
|
+
print("slice %s done: %s landed" % (rec["id"], rec["branch"]))
|
|
1565
|
+
rows = tt.pending(root)
|
|
1566
|
+
if not rows:
|
|
1567
|
+
print("no open or stopped slice")
|
|
1568
|
+
for rec in rows:
|
|
1569
|
+
print(resume_text(root, rec))
|
|
1570
|
+
return 0
|
|
1571
|
+
if sub == "resume":
|
|
1572
|
+
if len(rest) != 1:
|
|
1573
|
+
return misuse("slice resume takes one ID")
|
|
1574
|
+
rec = tt.find_slice(root, rest[0], os.environ.get("TEZGAH_SESSION"))
|
|
1575
|
+
if rec is None:
|
|
1576
|
+
return problems(["no slice record %s under %s" % (rest[0], tt.slice_dir(root))])
|
|
1577
|
+
print(resume_text(root, rec))
|
|
1578
|
+
tt.close_slice(root, rec["id"], "resumed")
|
|
1579
|
+
return 0
|
|
1580
|
+
if sub == "stats":
|
|
1581
|
+
opts, pos, bad = parse_options(rest, ("--window",), ("--json",))
|
|
1582
|
+
if bad or pos:
|
|
1583
|
+
return misuse(bad or "slice stats takes [--window DAYS] [--json]")
|
|
1584
|
+
window = opts.get("--window", str(STATS_WINDOW))
|
|
1585
|
+
if not window.isdigit() or int(window) < 1:
|
|
1586
|
+
return misuse("--window takes a whole number of days")
|
|
1587
|
+
stats = slice_stats(tt.slices(root), int(window))
|
|
1588
|
+
if opts.get("--json"):
|
|
1589
|
+
print(json.dumps(stats, indent=2, sort_keys=True))
|
|
1590
|
+
return 0
|
|
1591
|
+
print("slices in the last %d days: %d" % (stats["window_days"], stats["slices"]))
|
|
1592
|
+
for key in ("status", "failure", "host"):
|
|
1593
|
+
print(" %s: %s" % (key, _counts(stats[key])))
|
|
1594
|
+
for row in stats["candidates"]:
|
|
1595
|
+
print("candidate: %s - %d events (%d%% of slices) - lever: %s" % (
|
|
1596
|
+
row["signature"], row["events"], round(row["share"] * 100), row["lever"]))
|
|
1597
|
+
if not stats["candidates"]:
|
|
1598
|
+
print("no signature at %d events or %d%% of the slices"
|
|
1599
|
+
% (STATS_EVENTS, round(STATS_SHARE * 100)))
|
|
1600
|
+
return 0
|
|
1601
|
+
return misuse(usage)
|
|
1602
|
+
|
|
1603
|
+
|
|
829
1604
|
def main(argv):
|
|
830
1605
|
if len(argv) < 2:
|
|
831
1606
|
print(__doc__, file=sys.stderr)
|
|
@@ -845,9 +1620,51 @@ def main(argv):
|
|
|
845
1620
|
return cmd_review(rest)
|
|
846
1621
|
if cmd == "close":
|
|
847
1622
|
return cmd_close(rest)
|
|
1623
|
+
if cmd == "tick":
|
|
1624
|
+
return cmd_tick(rest)
|
|
1625
|
+
if cmd == "note":
|
|
1626
|
+
return cmd_note(rest)
|
|
1627
|
+
if cmd == "spike":
|
|
1628
|
+
return cmd_spike(rest)
|
|
1629
|
+
if cmd == "slice":
|
|
1630
|
+
return cmd_slice(rest)
|
|
848
1631
|
return misuse("unknown command %r (start, phase, allow, stop, status, review, "
|
|
849
|
-
"close)" % cmd)
|
|
1632
|
+
"close, tick, note, spike, slice)" % cmd)
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
class ReaderMayLeave:
|
|
1636
|
+
"""sys.stdout for a reader that may leave early (`tezgah-task stop | head
|
|
1637
|
+
-1`): a write or flush that meets the closed pipe points fd 1 at devnull
|
|
1638
|
+
and carries on, so main always finishes its work and returns its own code,
|
|
1639
|
+
buffered or not (PYTHONUNBUFFERED=1 meets the pipe at every print). A
|
|
1640
|
+
wrapper rather than SIGPIPE's default, which kills the process by signal
|
|
1641
|
+
(status 141) mid-write."""
|
|
1642
|
+
|
|
1643
|
+
def __init__(self, stream):
|
|
1644
|
+
self._stream = stream
|
|
1645
|
+
|
|
1646
|
+
def __getattr__(self, name):
|
|
1647
|
+
return getattr(self._stream, name)
|
|
1648
|
+
|
|
1649
|
+
def _gone(self):
|
|
1650
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), self._stream.fileno())
|
|
1651
|
+
|
|
1652
|
+
def write(self, text):
|
|
1653
|
+
try:
|
|
1654
|
+
return self._stream.write(text)
|
|
1655
|
+
except BrokenPipeError:
|
|
1656
|
+
self._gone()
|
|
1657
|
+
return len(text)
|
|
1658
|
+
|
|
1659
|
+
def flush(self):
|
|
1660
|
+
try:
|
|
1661
|
+
self._stream.flush()
|
|
1662
|
+
except BrokenPipeError:
|
|
1663
|
+
self._gone()
|
|
850
1664
|
|
|
851
1665
|
|
|
852
1666
|
if __name__ == "__main__":
|
|
853
|
-
sys.
|
|
1667
|
+
sys.stdout = ReaderMayLeave(sys.stdout)
|
|
1668
|
+
code = main(sys.argv)
|
|
1669
|
+
sys.stdout.flush() # here, not in the interpreter's exit flush, which would print the EPIPE
|
|
1670
|
+
sys.exit(code)
|