@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +140 -1
  2. package/MANIFEST +7 -0
  3. package/README.md +6 -3
  4. package/bin/tezgah-decisions +6 -14
  5. package/bin/tezgah-design +21 -5
  6. package/bin/tezgah-docs +18 -16
  7. package/bin/tezgah-doctor +152 -0
  8. package/bin/tezgah-lessons +4 -1
  9. package/bin/tezgah-research +249 -17
  10. package/bin/tezgah-route +70 -8
  11. package/bin/tezgah-setup +26 -13
  12. package/bin/tezgah-task +877 -60
  13. package/bin/tezgah-taste +56 -15
  14. package/docs/architecture.md +28 -11
  15. package/docs/clarity-baseline.json +2 -2
  16. package/docs/contract.md +108 -24
  17. package/docs/environment.md +3 -1
  18. package/docs/evidence.md +209 -28
  19. package/docs/feature-audit.md +1 -1
  20. package/docs/gate.md +78 -23
  21. package/docs/glossary.md +4 -4
  22. package/docs/hosts.md +8 -8
  23. package/docs/index.json +11 -5
  24. package/docs/judge.md +9 -3
  25. package/docs/models.md +75 -14
  26. package/docs/operations.md +133 -3
  27. package/docs/research.md +349 -15
  28. package/docs/skills.md +1 -1
  29. package/docs/status-line.md +5 -5
  30. package/docs/testing.md +20 -13
  31. package/docs/vision.md +12 -4
  32. package/hooks/hooks.json +2 -2
  33. package/hooks/lang/en.json +323 -0
  34. package/hooks/lang/tr.json +344 -0
  35. package/hooks/projects-auto-init.py +29 -1
  36. package/hooks/projects-posttooluse.py +24 -5
  37. package/hooks/projects-pretooluse.py +9 -4
  38. package/hooks/projects-stop.py +28 -16
  39. package/hooks/tezgah_agents.py +53 -17
  40. package/hooks/tezgah_clarity.py +7 -3
  41. package/hooks/tezgah_context.py +489 -438
  42. package/hooks/tezgah_gate.py +996 -80
  43. package/hooks/tezgah_integrity.py +1547 -197
  44. package/hooks/tezgah_models.py +427 -45
  45. package/hooks/tezgah_paths.py +147 -16
  46. package/hooks/tezgah_policy.py +261 -129
  47. package/hooks/tezgah_rank.py +29 -27
  48. package/hooks/tezgah_replay.py +200 -30
  49. package/hooks/tezgah_research.py +1303 -14
  50. package/hooks/tezgah_stopspec.py +7 -0
  51. package/hooks/tezgah_store.py +74 -27
  52. package/hooks/tezgah_task.py +1201 -40
  53. package/hooks/tezgah_taste.py +8 -4
  54. package/hosts/codex/hook.py +32 -13
  55. package/hosts/cursor/hook.py +32 -14
  56. package/hosts/dsh/hooks.json +2 -2
  57. package/hosts/omp/hook.py +44 -23
  58. package/hosts/omp/tezgah-hook.ts.in +93 -7
  59. package/hosts/opencode/plugins/tezgah.js +273 -6
  60. package/output-styles/tezgah.md +76 -42
  61. package/package.json +1 -1
  62. package/skills/i-have-adhd/EVALS.md +22 -2
  63. package/skills/i-have-adhd/SKILL.md +12 -5
  64. package/skills/plan-add/SKILL.md +27 -11
  65. package/skills/plan-add/render_table.py +8 -3
  66. package/skills/plan-sync/SKILL.md +5 -3
  67. package/skills/ponytail/EVALS.md +21 -2
  68. package/skills/ponytail/SKILL.md +4 -4
  69. package/skills/research/SKILL.md +89 -1
  70. package/skills/tezgah-contract/EVALS.md +52 -7
  71. 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 at most one open plan carries a phase
14
- 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
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` 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`
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 as every reader sees it - resolved by the module, so the
134
- CLI and the gate cannot disagree about which plan is active."""
135
- 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)
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
- 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 ".")
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
- if os.path.isfile(os.path.join(root, "tests", "impacted.py")):
362
- print(" the one full run here is `python3 tests/impacted.py --all`")
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 is recorded fix-later under the plan's "
373
- "## State" % REVIEW_ROUNDS)
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 `git diff %s..HEAD`, the "
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(root)
384
- size = changed_lines(root, base)
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 `git diff %s..HEAD` and the test "
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 recorded fix-later "
396
- "under ## State and the verdict is `approve`")
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 = plan_path(root, args[0])
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
- 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))
471
573
  for other in plans(root):
472
574
  if other == path:
473
575
  continue
474
- if tt.frontmatter(read(other)).get("phase"):
475
- # one active task at a time: the plan that had the phase loses it
476
- # here rather than both being active and the gate reading whichever
477
- # 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
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
- 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))
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
- task = active_here(repo_root(os.getcwd()))
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>\"`; record a minor or suggestion one fix-later "
579
- "under the plan's ## State; `close %s discarded` needs no review"
580
- % (path, REVIEW_ROUNDS, ident, ident))
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 = plan_path(repo_root(os.getcwd()), args[0])
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
- sha = (git_out(root, "rev-parse", "HEAD") or "")[:12]
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
- os.makedirs(os.path.dirname(target), exist_ok=True)
652
- os.replace(path, target)
947
+ tt.close_record(path, target, **fields)
653
948
  except OSError as exc:
654
- return problems(["cannot move %s: %s" % (path, exc)])
949
+ return problems(["cannot close %s: %s" % (path, exc)])
655
950
  print("moved %s -> %s" % (path, target))
656
- 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:
657
955
  print(line)
658
- 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)
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 prune_worktrees(root):
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 removal is Orca's (its
724
- card, terminals and merged branch go with it), else `git worktree remove`
725
- and a safe `git branch -d`. When Orca is installed but does not answer, its
726
- 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."""
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
- # the remote's default branch too: a PR merged on the host lands there first
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.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)