@r1z4x/tezgah 1.7.0 → 1.8.3
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 +74 -1
- package/MANIFEST +2 -0
- package/bin/tezgah-decisions +6 -14
- package/bin/tezgah-design +21 -5
- package/bin/tezgah-lessons +4 -1
- package/bin/tezgah-research +15 -7
- package/bin/tezgah-route +22 -7
- package/bin/tezgah-task +320 -75
- package/bin/tezgah-taste +8 -5
- package/docs/architecture.md +24 -7
- package/docs/environment.md +3 -1
- package/docs/evidence.md +9 -1
- package/docs/gate.md +24 -7
- package/docs/models.md +40 -12
- package/docs/operations.md +31 -1
- package/docs/research.md +3 -2
- package/docs/testing.md +18 -11
- package/hooks/projects-auto-init.py +29 -1
- package/hooks/tezgah_agents.py +24 -7
- package/hooks/tezgah_context.py +71 -35
- package/hooks/tezgah_gate.py +430 -64
- package/hooks/tezgah_integrity.py +108 -41
- package/hooks/tezgah_models.py +90 -30
- package/hooks/tezgah_paths.py +72 -6
- package/hooks/tezgah_policy.py +3 -3
- package/hooks/tezgah_research.py +9 -5
- package/hooks/tezgah_task.py +319 -67
- package/hooks/tezgah_taste.py +8 -4
- package/hosts/codex/hook.py +23 -3
- package/hosts/cursor/hook.py +11 -0
- package/hosts/opencode/plugins/tezgah.js +171 -4
- package/package.json +1 -1
- package/skills/plan-add/SKILL.md +23 -9
- package/skills/plan-add/render_table.py +8 -3
- package/skills/tezgah-contract/SKILL.md +3 -3
package/bin/tezgah-task
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
tezgah-task close <NNN|path> done|discarded [--resolved TEXT] move a plan to .tezgah/plans/done, deactivate it, remove merged worktrees
|
|
11
11
|
tezgah-task tick <NNN|path> <n> [--undo] tick (or untick) Acceptance item n, counted from 1
|
|
12
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:`)
|
|
13
14
|
tezgah-task slice open --id ID [--kind agent|job] [--host H] [--agent A] [--brief TEXT|--brief-file F] [--parent P] [--cwd PATH]
|
|
14
15
|
record a delegated slice (a re-open counts one more attempt)
|
|
15
16
|
tezgah-task slice done ID | slice stop ID [--class C] close a slice record
|
|
@@ -20,16 +21,26 @@
|
|
|
20
21
|
tezgah-task slice merge-plan [--base REF] the done slice branches in merge order, with their forecast conflicts
|
|
21
22
|
|
|
22
23
|
The record is the plan file itself: `phase:` activates it, `allowed_paths:` is
|
|
23
|
-
the scope a write has to stay inside, and
|
|
24
|
-
|
|
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
|
|
25
35
|
here itself as the work moves - the lifecycle is never handed back to the user -
|
|
26
36
|
and this CLI is the record's only writer: the gate refuses a write to the plan
|
|
27
37
|
file, because the checks below are made here and a retyped frontmatter would
|
|
28
38
|
skip them. `phase implementation` (or `verification`) is what lets a write
|
|
29
39
|
through, so the phase changes when the work reaches that point and not before.
|
|
30
|
-
`tick` and `
|
|
31
|
-
an Acceptance box
|
|
32
|
-
the frontmatter but `updated:`, and
|
|
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.
|
|
33
44
|
|
|
34
45
|
The allowlist is the plan's own: plan-add writes `allowed_paths:` when it
|
|
35
46
|
creates the plan, and `start` without `--allow` keeps it. A writing phase with
|
|
@@ -43,7 +54,16 @@ project's HEAD it read) and `review_round:`, so the next round reads only
|
|
|
43
54
|
`git diff <reviewed_sha>..HEAD`, and refuses a third round: the budget is two
|
|
44
55
|
rounds per plan; `close NNN done` refuses a plan whose review is missing or not
|
|
45
56
|
`approve`, unless both rounds are spent and `--resolved` records the fix the
|
|
46
|
-
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`
|
|
47
67
|
removes each linked worktree whose branch has landed in HEAD, `main`, `master`
|
|
48
68
|
or the remote's default branch (an ancestor of it, or a squash whose merge
|
|
49
69
|
would change nothing) and which holds nothing else (no uncommitted or unknown
|
|
@@ -80,9 +100,10 @@ on a spawn call or by `slice open`, so a parent crash, an omp `/clear`, a quota
|
|
|
80
100
|
stop or a model or host switch loses no slice. `reconcile` reads each open omp
|
|
81
101
|
record's child transcript and closes the ones that ended, then prints one
|
|
82
102
|
resume brief per open or stopped slice; `--prune` first runs the same worktree
|
|
83
|
-
removal `close` does, deletes (`git branch -
|
|
84
|
-
landed and is checked out nowhere - never main, master
|
|
85
|
-
and marks done each record whose branch landed with a
|
|
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.
|
|
86
107
|
`resume` prints the host's native path first (omp `write agent://<id>` while
|
|
87
108
|
the parent session lives, Claude `SendMessage`), then a brief any host or model
|
|
88
109
|
can re-dispatch: the original brief plus the last commit, the branch and the
|
|
@@ -105,6 +126,7 @@ import glob
|
|
|
105
126
|
import json
|
|
106
127
|
import os
|
|
107
128
|
import re
|
|
129
|
+
import shlex
|
|
108
130
|
import subprocess
|
|
109
131
|
import sys
|
|
110
132
|
import time
|
|
@@ -112,7 +134,7 @@ import time
|
|
|
112
134
|
HERE = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
|
113
135
|
sys.path.insert(0, os.path.join(HERE, "hooks"))
|
|
114
136
|
import tezgah_task as tt # noqa: E402
|
|
115
|
-
from tezgah_paths import say # noqa: E402
|
|
137
|
+
from tezgah_paths import project_top, say # noqa: E402
|
|
116
138
|
|
|
117
139
|
|
|
118
140
|
def repo_root(path):
|
|
@@ -122,7 +144,7 @@ def repo_root(path):
|
|
|
122
144
|
out = subprocess.run(["git", "-C", path, "rev-parse", "--show-toplevel"],
|
|
123
145
|
capture_output=True, text=True)
|
|
124
146
|
if out.returncode == 0 and out.stdout.strip():
|
|
125
|
-
return out.stdout.strip()
|
|
147
|
+
return project_top(out.stdout.strip())
|
|
126
148
|
except OSError:
|
|
127
149
|
pass
|
|
128
150
|
return os.path.realpath(path)
|
|
@@ -168,9 +190,12 @@ def plan_path(root, ref):
|
|
|
168
190
|
|
|
169
191
|
|
|
170
192
|
def active_here(root):
|
|
171
|
-
"""The active task
|
|
172
|
-
|
|
173
|
-
|
|
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)
|
|
174
199
|
|
|
175
200
|
|
|
176
201
|
def problems(items):
|
|
@@ -218,6 +243,15 @@ def allow_source(text):
|
|
|
218
243
|
return allow, "any path in the repo (no allowlist)"
|
|
219
244
|
|
|
220
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
|
+
|
|
221
255
|
def checkpoint_state(value, path):
|
|
222
256
|
"""The checkpoint line `show` prints for a plan in `implementation`: the sha
|
|
223
257
|
the work returns to, or the commit the phase waits for - named with the sha it
|
|
@@ -234,7 +268,10 @@ def checkpoint_state(value, path):
|
|
|
234
268
|
state the phase is in."""
|
|
235
269
|
sha = tt.checkpoint_sha(value)
|
|
236
270
|
if sha:
|
|
237
|
-
|
|
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 ".")
|
|
238
275
|
head = git_out(root, "rev-parse", "HEAD")
|
|
239
276
|
listed = git_out(root, "status", "--porcelain")
|
|
240
277
|
if not head or head != sha or not listed:
|
|
@@ -245,7 +282,7 @@ def checkpoint_state(value, path):
|
|
|
245
282
|
print(" checkpoint: pending on %s - the tree held uncommitted changes "
|
|
246
283
|
"when the phase began, so this phase's writes are refused until "
|
|
247
284
|
"the commit that moves HEAD off it: %s"
|
|
248
|
-
% (sha, tt.checkpoint_command(path)))
|
|
285
|
+
% (sha, tt.checkpoint_command(path, holder)))
|
|
249
286
|
elif value:
|
|
250
287
|
print(" checkpoint: %s" % value)
|
|
251
288
|
else:
|
|
@@ -373,7 +410,6 @@ def changed_lines(root, base):
|
|
|
373
410
|
# tier"): (the file that marks the repo has it, the command), in run order.
|
|
374
411
|
MERGE_CHECKS = (
|
|
375
412
|
("tests/impacted.py", "python3 tests/impacted.py --all"),
|
|
376
|
-
("tests/neuter_matrix.py", "TEZGAH_E2E_STRICT=1 python3 tests/neuter_matrix.py"),
|
|
377
413
|
("bin/tezgah-docs", "python3 bin/tezgah-docs --citations"),
|
|
378
414
|
("bin/tezgah-docs", "python3 bin/tezgah-docs --clarity"),
|
|
379
415
|
("skills/plan-add/render_table.py",
|
|
@@ -399,6 +435,11 @@ def verification_steps(text, path):
|
|
|
399
435
|
# the project, not the private repository `.tezgah` may hold
|
|
400
436
|
root = os.path.realpath(os.path.join(os.path.dirname(path), "..", "..", ".."))
|
|
401
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"
|
|
402
443
|
prior = fields.get("review_round") or ""
|
|
403
444
|
rounds = int(prior) if prior.isdigit() else 0
|
|
404
445
|
print("verification - required before this plan is done:")
|
|
@@ -430,21 +471,21 @@ def verification_steps(text, path):
|
|
|
430
471
|
if rounds:
|
|
431
472
|
print(" 2. review round 2 (the last): each confirmed critical or major "
|
|
432
473
|
"finding is fixed with a regression test that failed first; one "
|
|
433
|
-
"fresh tezgah-reviewer reads only the delta
|
|
474
|
+
"fresh tezgah-reviewer reads only the delta `%s %s..HEAD`, the "
|
|
434
475
|
"round-1 hunks and direct callers of the symbols it touches, and the "
|
|
435
476
|
"test output above, and marks every round-1 finding closed or open; "
|
|
436
|
-
"it does not re-run the suite" % reviewed)
|
|
477
|
+
"it does not re-run the suite" % (diff, reviewed))
|
|
437
478
|
else:
|
|
438
|
-
base = diff_base(
|
|
439
|
-
size = changed_lines(
|
|
479
|
+
base = diff_base(where)
|
|
480
|
+
size = changed_lines(where, base)
|
|
440
481
|
print(" 2. one review of the whole plan, at most %d rounds: spawn fresh "
|
|
441
482
|
"tezgah-reviewer subagents in ONE message, each owning one dimension "
|
|
442
483
|
"- correctness and contract, security, tests and performance - or one "
|
|
443
|
-
"file group of a large diff, over
|
|
484
|
+
"file group of a large diff, over `%s %s..HEAD` and the test "
|
|
444
485
|
"output above, then merge their findings into one verdict. Under %d "
|
|
445
486
|
"changed lines one reviewer reads every dimension (this diff: %s), "
|
|
446
487
|
"unless the diff touches auth, secrets or a gate, which keeps a "
|
|
447
|
-
"security reviewer" % (REVIEW_ROUNDS, base, SMALL_DIFF,
|
|
488
|
+
"security reviewer" % (REVIEW_ROUNDS, diff, base, SMALL_DIFF,
|
|
448
489
|
"unknown" if size is None else size))
|
|
449
490
|
print(" only a confirmed critical or major finding is `changes` and "
|
|
450
491
|
"opens round 2; a minor or suggestion one is fixed in the session "
|
|
@@ -474,7 +515,7 @@ def scope_problem(phase, allow, any_path):
|
|
|
474
515
|
def cmd_start(args):
|
|
475
516
|
if not args:
|
|
476
517
|
return misuse("start needs a plan id or path")
|
|
477
|
-
root = repo_root(os.getcwd())
|
|
518
|
+
root = main_root(repo_root(os.getcwd()))
|
|
478
519
|
path, problem = open_plan(args[0])
|
|
479
520
|
phase = None
|
|
480
521
|
allow = []
|
|
@@ -524,14 +565,25 @@ def cmd_start(args):
|
|
|
524
565
|
or phase_problems(phase, text, path))
|
|
525
566
|
if bad:
|
|
526
567
|
return problems(bad)
|
|
527
|
-
|
|
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))
|
|
528
573
|
for other in plans(root):
|
|
529
574
|
if other == path:
|
|
530
575
|
continue
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
|
535
587
|
bad = write_fields(other, phase="", updated=today())
|
|
536
588
|
if bad:
|
|
537
589
|
return problems(bad)
|
|
@@ -563,10 +615,12 @@ def cmd_phase(args):
|
|
|
563
615
|
granted = (any_path or tt.frontmatter(text).get("allowed_from")
|
|
564
616
|
== ANY_PATH)
|
|
565
617
|
bad = (scope_problem(phase, task["allowed_paths"], granted)
|
|
566
|
-
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 []))
|
|
567
620
|
if bad:
|
|
568
621
|
return problems(bad)
|
|
569
|
-
|
|
622
|
+
where = task.get("worktree") or tt.plan_holders(main_root(root)).get(task["id"])
|
|
623
|
+
fields.update(checkpoint_fields(where or root, phase))
|
|
570
624
|
bad = write_fields(task["path"], **fields)
|
|
571
625
|
if bad:
|
|
572
626
|
return problems(bad)
|
|
@@ -590,10 +644,14 @@ def cmd_allow(args):
|
|
|
590
644
|
return misuse("allow takes globs, or --any-path alone to lift the scope")
|
|
591
645
|
if not why:
|
|
592
646
|
return misuse("allow takes --why \"<what in the plan reaches these paths>\"")
|
|
593
|
-
|
|
647
|
+
root = repo_root(os.getcwd())
|
|
648
|
+
task = active_here(root)
|
|
594
649
|
if not task:
|
|
595
650
|
return problems(["no active task to widen: start one with "
|
|
596
651
|
"`tezgah-task start <id> --phase P --allow GLOB`"])
|
|
652
|
+
bad = held_overlap(root, task["id"], args)
|
|
653
|
+
if bad:
|
|
654
|
+
return problems(bad)
|
|
597
655
|
# --any-path replaces the list with an empty one, which reads as "any path in
|
|
598
656
|
# the repo" - and the gate skips the scope check entirely then, so this is
|
|
599
657
|
# the way back out of a scope that turned out to be too narrow. A flag and
|
|
@@ -619,11 +677,42 @@ def cmd_stop(args):
|
|
|
619
677
|
return show(task["path"])
|
|
620
678
|
|
|
621
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
|
+
|
|
622
711
|
def open_plan(ref):
|
|
623
712
|
"""(path, problem) for a plan under `.tezgah/plans/open/`: `tick` and `note`
|
|
624
713
|
write past the gate, so they take no other file (review of plan 083, F4:
|
|
625
714
|
`note README.md next ...` rewrote any markdown file with a Next section)."""
|
|
626
|
-
root = repo_root(os.getcwd())
|
|
715
|
+
root = main_root(repo_root(os.getcwd()))
|
|
627
716
|
path, problem = plan_path(root, ref)
|
|
628
717
|
if problem:
|
|
629
718
|
return None, problem
|
|
@@ -642,16 +731,15 @@ def cmd_tick(args):
|
|
|
642
731
|
path, problem = open_plan(args[0])
|
|
643
732
|
if problem:
|
|
644
733
|
return problems([problem])
|
|
645
|
-
items = tt.acceptance_items(read(path))
|
|
646
734
|
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
735
|
try:
|
|
652
|
-
|
|
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)
|
|
653
738
|
except OSError as exc:
|
|
654
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)])
|
|
655
743
|
bad = write_fields(path, updated=today())
|
|
656
744
|
if bad:
|
|
657
745
|
return problems(bad)
|
|
@@ -683,6 +771,23 @@ def cmd_note(args):
|
|
|
683
771
|
return 0
|
|
684
772
|
|
|
685
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
|
+
|
|
686
791
|
def review_verdict(text):
|
|
687
792
|
"""The verdict of the plan's `review: <reviewer> <verdict> <date>` line, or
|
|
688
793
|
None when it has none or names a verdict this CLI does not write."""
|
|
@@ -769,7 +874,11 @@ def cmd_review(args):
|
|
|
769
874
|
int(since) if (since or "").isdigit() else 0)
|
|
770
875
|
if bad:
|
|
771
876
|
return problems(bad)
|
|
772
|
-
|
|
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]
|
|
773
882
|
record = {"review": "%s %s %s" % (args[1], args[2], today()),
|
|
774
883
|
"review_round": str(int(prior) + 1 if prior.isdigit() else 1),
|
|
775
884
|
"updated": today()}
|
|
@@ -793,7 +902,7 @@ def cmd_close(args):
|
|
|
793
902
|
return misuse("--resolved takes the fix and the test that proves it")
|
|
794
903
|
if len(args) != 2 or args[1] not in ("done", "discarded"):
|
|
795
904
|
return misuse("close takes <NNN|path> done|discarded [--resolved TEXT]")
|
|
796
|
-
root = repo_root(os.getcwd())
|
|
905
|
+
root = main_root(repo_root(os.getcwd()))
|
|
797
906
|
path, problem = plan_path(root, args[0])
|
|
798
907
|
if problem:
|
|
799
908
|
return problems([problem])
|
|
@@ -801,6 +910,14 @@ def cmd_close(args):
|
|
|
801
910
|
return problems(["%s is not under %s" % (path, open_dir(root))])
|
|
802
911
|
text = read(path)
|
|
803
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)])
|
|
804
921
|
# closing the active task deactivates it in the same write: the plan leaves
|
|
805
922
|
# open/ anyway, and a separate `stop` first was one more step for nobody
|
|
806
923
|
fields = {"status": args[1], "updated": today()}
|
|
@@ -819,19 +936,74 @@ def cmd_close(args):
|
|
|
819
936
|
return problems(["%s records no approving review: a fresh reviewer reads "
|
|
820
937
|
"the diff, then `tezgah-task review %s <reviewer> approve`"
|
|
821
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))
|
|
822
945
|
target = os.path.join(root, ".tezgah", "plans", "done", os.path.basename(path))
|
|
823
|
-
bad = write_fields(path, **fields)
|
|
824
|
-
if bad:
|
|
825
|
-
return problems(bad)
|
|
826
946
|
try:
|
|
827
|
-
|
|
828
|
-
os.replace(path, target)
|
|
947
|
+
tt.close_record(path, target, **fields)
|
|
829
948
|
except OSError as exc:
|
|
830
|
-
return problems(["cannot
|
|
949
|
+
return problems(["cannot close %s: %s" % (path, exc)])
|
|
831
950
|
print("moved %s -> %s" % (path, target))
|
|
832
|
-
|
|
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:
|
|
833
955
|
print(line)
|
|
834
|
-
|
|
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)
|
|
835
1007
|
|
|
836
1008
|
|
|
837
1009
|
# Ignored output a slice regenerates on its own. Any other ignored path - an
|
|
@@ -901,18 +1073,33 @@ def landing_bases(root):
|
|
|
901
1073
|
if git_out(root, "rev-parse", "-q", "--verify", b)]
|
|
902
1074
|
|
|
903
1075
|
|
|
904
|
-
def
|
|
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):
|
|
905
1091
|
"""Remove every linked worktree of the repository whose branch has landed and
|
|
906
1092
|
which holds nothing else, so a closed plan leaves no checkout behind; one line
|
|
907
1093
|
per linked worktree, removed or kept with the reason. The main checkout and
|
|
908
|
-
the one `close` runs in always stay. Under Orca the
|
|
909
|
-
card, terminals and merged branch go with it), else
|
|
910
|
-
|
|
911
|
-
|
|
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."""
|
|
912
1099
|
import tezgah_orca
|
|
913
1100
|
from tezgah_paths import worktrees
|
|
914
1101
|
found = worktrees(root)
|
|
915
|
-
here = os.path.realpath(root)
|
|
1102
|
+
here = os.path.realpath(keep or root)
|
|
916
1103
|
linked = [p for p in found[1:] if p != here]
|
|
917
1104
|
if not linked:
|
|
918
1105
|
return []
|
|
@@ -943,11 +1130,24 @@ def prune_worktrees(root):
|
|
|
943
1130
|
else:
|
|
944
1131
|
gone = git_out(root, "worktree", "remove", path) is not None
|
|
945
1132
|
how = "git worktree remove %s" % path
|
|
946
|
-
if gone:
|
|
947
|
-
git_out(root, "branch", "-d", branch)
|
|
948
1133
|
why = None if gone else "`%s` failed; run it and report its error" % how
|
|
949
1134
|
out.append("worktree %s: %s (%s)" % ("kept" if why else "removed", path,
|
|
950
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))
|
|
951
1151
|
return out
|
|
952
1152
|
|
|
953
1153
|
|
|
@@ -1060,16 +1260,28 @@ def resume_text(root, rec):
|
|
|
1060
1260
|
return "\n".join(lines)
|
|
1061
1261
|
tree = rec.get("worktree") or ""
|
|
1062
1262
|
cwd = rec.get("cwd") or ""
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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"))
|
|
1073
1285
|
# a Claude record named by a 12-hex brief digest addresses no agent: no
|
|
1074
1286
|
# SendMessage line for it, only the re-dispatch
|
|
1075
1287
|
named = not re.fullmatch(r"[0-9a-f]{12}", sid)
|
|
@@ -1100,7 +1312,8 @@ def own_commits(root, rec):
|
|
|
1100
1312
|
|
|
1101
1313
|
|
|
1102
1314
|
def prune_branches(root, bases):
|
|
1103
|
-
"""`git branch -
|
|
1315
|
+
"""`git branch -D` every local branch whose work landed in `bases` (`_landed`,
|
|
1316
|
+
a squash merge too), that no
|
|
1104
1317
|
worktree has checked out, that tracks no upstream (a long-lived branch such
|
|
1105
1318
|
as `develop` tracks one) and that carries a commit of its own (its reflog
|
|
1106
1319
|
names one, the guard `_keeps` applies to a worktree: a pre-created branch
|
|
@@ -1122,11 +1335,7 @@ def prune_branches(root, bases):
|
|
|
1122
1335
|
continue
|
|
1123
1336
|
if not any(_landed(root, branch, b) for b in bases):
|
|
1124
1337
|
continue
|
|
1125
|
-
|
|
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]))
|
|
1338
|
+
out.append(drop_branch(root, branch) or "branch deleted: %s" % branch)
|
|
1130
1339
|
return out
|
|
1131
1340
|
|
|
1132
1341
|
|
|
@@ -1415,11 +1624,47 @@ def main(argv):
|
|
|
1415
1624
|
return cmd_tick(rest)
|
|
1416
1625
|
if cmd == "note":
|
|
1417
1626
|
return cmd_note(rest)
|
|
1627
|
+
if cmd == "spike":
|
|
1628
|
+
return cmd_spike(rest)
|
|
1418
1629
|
if cmd == "slice":
|
|
1419
1630
|
return cmd_slice(rest)
|
|
1420
1631
|
return misuse("unknown command %r (start, phase, allow, stop, status, review, "
|
|
1421
|
-
"close, tick, note, slice)" % 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()
|
|
1422
1664
|
|
|
1423
1665
|
|
|
1424
1666
|
if __name__ == "__main__":
|
|
1425
|
-
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)
|