@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/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 at most one open plan carries a phase
24
- because `start` clears the others. The orchestrating session runs every command
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 `note` are the record's body writers for the same reason: they write
31
- an Acceptance box or the State/Next text on any open plan, active or not, keep
32
- the frontmatter but `updated:`, and commit nothing.
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` on the active task clears its phase in the same write, then
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 -d`) every local branch that has
84
- landed and is checked out nowhere - never main, master or the current branch -
85
- and marks done each record whose branch landed with a commit of its own.
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 as every reader sees it - resolved by the module, so the
172
- CLI and the gate cannot disagree about which plan is active."""
173
- return tt.active(root, root)
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
- root = repo_root(os.path.dirname(path) or ".")
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 `git diff %s..HEAD`, the "
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(root)
439
- size = changed_lines(root, base)
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 `git diff %s..HEAD` and the test "
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
- fields.update(checkpoint_fields(root, phase))
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
- if tt.frontmatter(read(other)).get("phase"):
532
- # one active task at a time: the plan that had the phase loses it
533
- # here rather than both being active and the gate reading whichever
534
- # sorts first
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
- fields.update(checkpoint_fields(root, phase))
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
- task = active_here(repo_root(os.getcwd()))
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
- tt.set_box(path, item["line"], not undo)
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
- sha = (git_out(root, "rev-parse", "HEAD") or "")[:12]
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
- os.makedirs(os.path.dirname(target), exist_ok=True)
828
- os.replace(path, target)
947
+ tt.close_record(path, target, **fields)
829
948
  except OSError as exc:
830
- return problems(["cannot move %s: %s" % (path, exc)])
949
+ return problems(["cannot close %s: %s" % (path, exc)])
831
950
  print("moved %s -> %s" % (path, target))
832
- for line in prune_worktrees(root):
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
- return 0
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 prune_worktrees(root):
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 removal is Orca's (its
909
- card, terminals and merged branch go with it), else `git worktree remove`
910
- and a safe `git branch -d`. When Orca is installed but does not answer, its
911
- agents cannot be checked, so every worktree stays."""
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
- where = (tree if tree and os.path.isdir(tree) else
1064
- cwd if cwd and os.path.isdir(cwd) else root)
1065
- last = git_out(where, "log", "-1", "--format=%h %s") or "(none)"
1066
- branch = (rec.get("branch") or git_out(where, "symbolic-ref", "-q", "--short", "HEAD")
1067
- or "(detached HEAD)")
1068
- # the journal's own directory is not the slice's work, ignored or not
1069
- dirty = [line[3:] for line in (git_out(where, "status", "--porcelain") or "").splitlines()
1070
- if not line[3:].startswith(".tezgah")]
1071
- resume = ("Resume: last commit %s on %s; uncommitted: %s; continue from there, "
1072
- "do not redo committed steps" % (last, branch, ", ".join(dirty) or "none"))
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 -d` every local branch whose work landed in `bases`, that no
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
- done = subprocess.run(["git", "-C", root, "branch", "-d", branch],
1126
- capture_output=True, text=True)
1127
- out.append("branch deleted: %s" % branch if done.returncode == 0 else
1128
- "branch kept: %s (`git branch -d %s` refused: %s)"
1129
- % (branch, branch, (done.stderr.strip().splitlines() or ["?"])[0]))
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.exit(main(sys.argv))
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)