@r1z4x/tezgah 1.7.0 → 1.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -1
- package/MANIFEST +2 -0
- package/bin/tezgah-decisions +6 -14
- package/bin/tezgah-design +21 -5
- package/bin/tezgah-lessons +4 -1
- package/bin/tezgah-research +15 -7
- package/bin/tezgah-route +22 -7
- package/bin/tezgah-task +320 -75
- package/bin/tezgah-taste +8 -5
- package/docs/architecture.md +21 -7
- package/docs/environment.md +3 -1
- package/docs/evidence.md +9 -1
- package/docs/gate.md +24 -7
- package/docs/models.md +40 -12
- package/docs/operations.md +31 -1
- package/docs/research.md +3 -2
- package/docs/testing.md +18 -11
- package/hooks/projects-auto-init.py +29 -1
- package/hooks/tezgah_agents.py +24 -7
- package/hooks/tezgah_context.py +67 -35
- package/hooks/tezgah_gate.py +430 -64
- package/hooks/tezgah_integrity.py +108 -41
- package/hooks/tezgah_models.py +90 -30
- package/hooks/tezgah_paths.py +44 -6
- package/hooks/tezgah_policy.py +3 -3
- package/hooks/tezgah_research.py +9 -5
- package/hooks/tezgah_task.py +319 -67
- package/hooks/tezgah_taste.py +8 -4
- package/hosts/codex/hook.py +23 -3
- package/hosts/cursor/hook.py +11 -0
- package/hosts/opencode/plugins/tezgah.js +171 -4
- package/package.json +1 -1
- package/skills/plan-add/SKILL.md +23 -9
- package/skills/plan-add/render_table.py +8 -3
- package/skills/tezgah-contract/SKILL.md +3 -3
package/hooks/tezgah_task.py
CHANGED
|
@@ -52,6 +52,7 @@ out to git and read omp transcripts; the Stop rule reads `pending` (fail open).
|
|
|
52
52
|
import contextlib
|
|
53
53
|
import os
|
|
54
54
|
import re
|
|
55
|
+
import shlex
|
|
55
56
|
|
|
56
57
|
TASK_PHASES = ("discovery", "implementation", "verification")
|
|
57
58
|
WRITE_PHASES = ("implementation", "verification")
|
|
@@ -61,12 +62,15 @@ def repo_root(cwd, base):
|
|
|
61
62
|
"""The git top-level at or above cwd and under base, else the first path
|
|
62
63
|
component below base. Walks up for a `.git` entry (dir or file: a worktree's
|
|
63
64
|
is a file); no subprocess. Same answer as tezgah_context.repo_root without
|
|
64
|
-
importing the context module into the gate's hot path
|
|
65
|
+
importing the context module into the gate's hot path; inside `.tezgah` the
|
|
66
|
+
project around it (`tezgah_paths.project_top`)."""
|
|
67
|
+
from tezgah_paths import project_top
|
|
65
68
|
cwd = os.path.realpath(cwd)
|
|
66
69
|
inside = os.path.realpath(base) if base else None
|
|
67
70
|
cur = cwd
|
|
68
71
|
while True:
|
|
69
72
|
if os.path.exists(os.path.join(cur, ".git")):
|
|
73
|
+
cur = project_top(cur)
|
|
70
74
|
if inside and _under(cur, inside):
|
|
71
75
|
return cur
|
|
72
76
|
break
|
|
@@ -164,6 +168,10 @@ _MARKER = re.compile(r"unverifiable(?![-\w])", re.I)
|
|
|
164
168
|
# names the word ("either the command or the word `unverifiable` followed by
|
|
165
169
|
# why") has no separator there and is not a declaration.
|
|
166
170
|
_SEPARATORS = ":,;(-"
|
|
171
|
+
# An unticked item the owner accepted as not met: `UNMET:` and the reason, upper
|
|
172
|
+
# case so prose that names the word is not a marker. `close NNN done` lets
|
|
173
|
+
# such an item through and refuses every other open box (open_items).
|
|
174
|
+
_UNMET = re.compile(r"(?<!\w)UNMET:\s*(\S.*)")
|
|
167
175
|
_SPAN = re.compile(r"`([^`\n]+)`")
|
|
168
176
|
# A runner whose test entry point is a bare subcommand: `pnpm test`, `cargo
|
|
169
177
|
# test`, `uv run pytest` carry no option and no path, yet each is the command.
|
|
@@ -276,6 +284,14 @@ def acceptance_gap(text):
|
|
|
276
284
|
return unproven(acceptance_items(text))
|
|
277
285
|
|
|
278
286
|
|
|
287
|
+
def open_items(text):
|
|
288
|
+
"""The plan's unticked Acceptance items that carry no `UNMET: <reason>`, as
|
|
289
|
+
(number counted from 1, item) pairs: what `close NNN done` refuses on. Plan
|
|
290
|
+
095 closed done with an open box because close read only the review."""
|
|
291
|
+
return [(n, item) for n, item in enumerate(acceptance_items(text), 1)
|
|
292
|
+
if not item["checked"] and not _UNMET.search(item["text"])]
|
|
293
|
+
|
|
294
|
+
|
|
279
295
|
# The `checkpoint:` values a plan can carry: a commit sha (the tree was clean at
|
|
280
296
|
# the phase move, so the boundary is already real), or `pending <sha>` while the
|
|
281
297
|
# tree still holds work no commit names - `<sha>` being the `HEAD` the commit the
|
|
@@ -314,13 +330,15 @@ def slug(path):
|
|
|
314
330
|
return name[:-3] if name.endswith(".md") else name
|
|
315
331
|
|
|
316
332
|
|
|
317
|
-
def checkpoint_command(path):
|
|
333
|
+
def checkpoint_command(path, worktree=None):
|
|
318
334
|
"""The one command that makes a plan's boundary real: a commit whose message
|
|
319
335
|
marks where the risky work starts. Named by the CLI's note and by the gate's
|
|
320
336
|
refusal - the same string, from the same caller-visible place - and running it
|
|
321
337
|
is also what clears the refusal: it moves HEAD off the sha the record wrote
|
|
322
|
-
(`checkpoint_sha`).
|
|
323
|
-
|
|
338
|
+
(`checkpoint_sha`). A plan held by its own worktree (`branch_task`) commits
|
|
339
|
+
there, so the command names that checkout."""
|
|
340
|
+
git = "git -C %s" % shlex.quote(worktree) if worktree else "git"
|
|
341
|
+
return '%s add -A && %s commit -m "checkpoint: before %s"' % (git, git, slug(path))
|
|
324
342
|
|
|
325
343
|
|
|
326
344
|
def spike_unanswered(text):
|
|
@@ -452,20 +470,53 @@ def parallel(rows):
|
|
|
452
470
|
return picked if len(picked) > 1 else []
|
|
453
471
|
|
|
454
472
|
|
|
455
|
-
def active(cwd, base):
|
|
456
|
-
"""The active task as a dict, or None.
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
473
|
+
def active(cwd, base, held=False):
|
|
474
|
+
"""The active task as a dict, or None. A linked worktree on a `plan/NNN-...`
|
|
475
|
+
branch answers plan NNN when that plan carries a phase (`branch_task`), so
|
|
476
|
+
two plans can run at once, one per worktree. Anywhere else - the main
|
|
477
|
+
checkout, or a worktree on another branch - it reads the main checkout's
|
|
478
|
+
`.tezgah/plans/open/*.md` sorted by name: the first plan carrying a valid
|
|
479
|
+
`phase` that no linked worktree holds (`plan_holders`). When every phased
|
|
480
|
+
plan is held there is none: a held plan's scope is its worktree's, and a
|
|
481
|
+
checkout outside it reading that scope held its writes to the wrong plan
|
|
482
|
+
(plan 097, D6). `held` answers the first phased plan then instead, marked
|
|
483
|
+
`"held": True`: the plan bin/tezgah-task's `phase`, `allow`, `stop` and
|
|
484
|
+
`status` act on from the main checkout, and the record the gate still
|
|
485
|
+
refuses a direct write to - while it holds no write to that plan's scope
|
|
486
|
+
outside its worktree. The context line never passes it.
|
|
487
|
+
Dict: {"id","title","phase","allowed_paths","checkpoint","branch","path"}:
|
|
488
|
+
id/title are the frontmatter values (id falls back to the filename's NNN),
|
|
489
|
+
checkpoint is the frontmatter value or None when the plan carries none,
|
|
490
|
+
path is the file.
|
|
461
491
|
Never raises: an unreadable directory or file means None (readers fail
|
|
462
492
|
open)."""
|
|
463
493
|
root = repo_root(cwd, base)
|
|
494
|
+
own = branch_task(root)
|
|
495
|
+
if own:
|
|
496
|
+
return own
|
|
497
|
+
from tezgah_paths import linked_main
|
|
498
|
+
main = linked_main(root) or root
|
|
499
|
+
records = _phased(main)
|
|
500
|
+
branches = _worktree_branches(main) if records else {}
|
|
501
|
+
for record in records:
|
|
502
|
+
if not any(_binds(record["id"], b) for b in branches):
|
|
503
|
+
return record
|
|
504
|
+
if not held or not records:
|
|
505
|
+
return None
|
|
506
|
+
records[0]["held"] = True
|
|
507
|
+
return records[0]
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _phased(root, ident=None):
|
|
511
|
+
"""Every open plan under `root` carrying a valid phase - plan `ident` only,
|
|
512
|
+
when one is named - as active()'s dicts, in name order; [] when there is
|
|
513
|
+
none or a plan file cannot be read."""
|
|
464
514
|
directory = os.path.join(root, ".tezgah", "plans", "open")
|
|
465
515
|
try:
|
|
466
516
|
names = sorted(os.listdir(directory))
|
|
467
517
|
except OSError:
|
|
468
|
-
return
|
|
518
|
+
return []
|
|
519
|
+
out = []
|
|
469
520
|
for name in names:
|
|
470
521
|
if not name.endswith(".md"):
|
|
471
522
|
continue
|
|
@@ -474,29 +525,158 @@ def active(cwd, base):
|
|
|
474
525
|
with open(path, "r", encoding="utf-8") as fh:
|
|
475
526
|
text = fh.read()
|
|
476
527
|
except OSError:
|
|
477
|
-
return
|
|
528
|
+
return []
|
|
478
529
|
fields = frontmatter(text)
|
|
479
530
|
if fields.get("phase") not in TASK_PHASES:
|
|
480
531
|
continue
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
532
|
+
ident_here = fields.get("id") or name.split("-")[0]
|
|
533
|
+
if ident is not None and ident_here != ident:
|
|
534
|
+
continue
|
|
535
|
+
out.append({"id": ident_here,
|
|
536
|
+
"title": fields.get("title", ""),
|
|
537
|
+
"phase": fields["phase"],
|
|
538
|
+
"allowed_paths": allowed_paths(text),
|
|
539
|
+
# read from the frontmatter already in hand, so the gate's
|
|
540
|
+
# checkpoint rule costs this module no second read of the plan
|
|
541
|
+
"checkpoint": fields.get("checkpoint"),
|
|
542
|
+
"branch": fields.get("branch", ""),
|
|
543
|
+
"path": path})
|
|
544
|
+
# A plan the repository shipped is data, not the user's task: its phase
|
|
545
|
+
# and allowlist would lock writes in a cloned repo (audit L-16). Only
|
|
546
|
+
# positive evidence turns the rule off - the index tracks `.tezgah` -
|
|
547
|
+
# never a can't-tell, which would switch the user's own guard off in
|
|
548
|
+
# silence (review R2); the answer is cached on the index's stat.
|
|
549
|
+
from tezgah_paths import workspace_tracked
|
|
550
|
+
if out and workspace_tracked(root):
|
|
551
|
+
return []
|
|
552
|
+
return out
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
_PLAN_BRANCH = re.compile(r"plan/(\d+)(?:-|$)")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def worktree_branch(top):
|
|
559
|
+
"""The branch the linked worktree `top` checks out, read from its admin
|
|
560
|
+
`HEAD` without a fork (the gate asks on every write); None for a detached
|
|
561
|
+
HEAD, a plain checkout or anything unreadable."""
|
|
562
|
+
try:
|
|
563
|
+
with open(os.path.join(top, ".git"), encoding="utf-8") as fh:
|
|
564
|
+
admin = fh.readline().strip()[len("gitdir:"):].strip()
|
|
565
|
+
with open(os.path.join(top, admin, "HEAD"), encoding="utf-8") as fh:
|
|
566
|
+
head = fh.readline().strip()
|
|
567
|
+
except (OSError, UnicodeDecodeError):
|
|
568
|
+
return None
|
|
569
|
+
prefix = "ref: refs/heads/"
|
|
570
|
+
return head[len(prefix):] if head.startswith(prefix) else None
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def plan_of_branch(branch):
|
|
574
|
+
"""The plan id a `plan/NNN-slug` branch names, else None."""
|
|
575
|
+
found = _PLAN_BRANCH.match(branch or "")
|
|
576
|
+
return found.group(1) if found else None
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _binds(ident, branch):
|
|
580
|
+
"""Does `branch` hold plan `ident`? It has to name the plan: `plan/NNN-...`.
|
|
581
|
+
A plan's declared `branch:` is one of those, and the slices it fans out to
|
|
582
|
+
run on others (`plan/097-records` beside `plan/097-workspace-integrity`):
|
|
583
|
+
each slice is held to its plan's scope, never to another plan's, which is
|
|
584
|
+
what a first-unheld-plan reading gave it (plan 097, D6)."""
|
|
585
|
+
return bool(branch) and plan_of_branch(branch) == ident
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _worktree_branches(main):
|
|
589
|
+
"""{branch: top} for every linked worktree of the main checkout `main`."""
|
|
590
|
+
from tezgah_paths import worktrees
|
|
591
|
+
out = {}
|
|
592
|
+
for top in worktrees(main)[1:]:
|
|
593
|
+
branch = worktree_branch(top)
|
|
594
|
+
if branch:
|
|
595
|
+
out[branch] = top
|
|
596
|
+
return out
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def plan_holders(main):
|
|
600
|
+
"""{plan id: worktree top} for every open plan under the main checkout
|
|
601
|
+
`main` that a linked worktree on one of its branches (`_binds`) holds, phase
|
|
602
|
+
or not. Of several, the worktree on the plan's declared `branch:` holds it -
|
|
603
|
+
the checkout its checkpoint, review and diff are read in - else the first
|
|
604
|
+
by branch name."""
|
|
605
|
+
branches = _worktree_branches(main)
|
|
606
|
+
out = {}
|
|
607
|
+
if not branches:
|
|
608
|
+
return out
|
|
609
|
+
for name, _, text in _plan_files(main, "open"):
|
|
610
|
+
fields = frontmatter(text)
|
|
611
|
+
ident = _plan_id(fields, name)
|
|
612
|
+
held = sorted(b for b in branches if _binds(ident, b))
|
|
613
|
+
if held:
|
|
614
|
+
declared = fields.get("branch", "")
|
|
615
|
+
out[ident] = branches[declared if declared in held else held[0]]
|
|
616
|
+
return out
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def branch_task(top):
|
|
620
|
+
"""Plan NNN's record (active()'s dict, plus "worktree": `top`, the checkout
|
|
621
|
+
whose HEAD and tree its checkpoint is read against) when `top` is a linked
|
|
622
|
+
worktree on a `plan/NNN-...` branch (`_binds`) and plan NNN, open under the
|
|
623
|
+
main checkout, carries a phase. None otherwise - another branch, a plan with
|
|
624
|
+
no phase, an unknown mapping - and the caller reads the main checkout's task
|
|
625
|
+
(plan 093)."""
|
|
626
|
+
ident = plan_of_branch(worktree_branch(top))
|
|
627
|
+
if ident is None:
|
|
628
|
+
return None
|
|
629
|
+
from tezgah_paths import linked_main
|
|
630
|
+
main = linked_main(top)
|
|
631
|
+
for record in _phased(main, ident) if main else ():
|
|
632
|
+
record["worktree"] = top
|
|
633
|
+
return record
|
|
497
634
|
return None
|
|
498
635
|
|
|
499
636
|
|
|
637
|
+
def worktree_task(path, cwd, base):
|
|
638
|
+
"""The record a write to `path` is held to when it lands in a sibling linked
|
|
639
|
+
worktree on a plan branch (`branch_task`), else None. A path under cwd's own
|
|
640
|
+
root is active()'s to answer, so it pays one realpath and nothing more."""
|
|
641
|
+
root = repo_root(cwd, base)
|
|
642
|
+
path = os.path.expanduser(path)
|
|
643
|
+
real = os.path.realpath(path if os.path.isabs(path) else os.path.join(cwd, path))
|
|
644
|
+
if _under(real, root):
|
|
645
|
+
return None
|
|
646
|
+
top = _sibling_worktree(real, root)
|
|
647
|
+
return branch_task(top) if top else None
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def main_relative(path, cwd, base):
|
|
651
|
+
"""`path` (absolute or relative to cwd) as a posix path relative to the main
|
|
652
|
+
checkout of cwd's repository - the checkout that keeps `.tezgah/plans` -
|
|
653
|
+
or None outside it. A session in a linked worktree reads the same records
|
|
654
|
+
as one in the main checkout."""
|
|
655
|
+
from tezgah_paths import linked_main
|
|
656
|
+
root = repo_root(cwd, base)
|
|
657
|
+
main = linked_main(root) or root
|
|
658
|
+
path = os.path.expanduser(path)
|
|
659
|
+
real = os.path.realpath(path if os.path.isabs(path) else os.path.join(cwd, path))
|
|
660
|
+
if real == main or not _under(real, main):
|
|
661
|
+
return None
|
|
662
|
+
return real[len(main) + 1:].replace(os.sep, "/")
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def phased_record(root, rel):
|
|
666
|
+
"""True when `rel` (relative to the main checkout `root`) is an open plan
|
|
667
|
+
carrying a valid phase - a record only bin/tezgah-task writes, whichever
|
|
668
|
+
plan the main checkout reads as active. Matched without case, since a
|
|
669
|
+
case-insensitive filesystem opens `.TEZGAH/PLANS/open/x.md` as the record."""
|
|
670
|
+
parts = (rel or "").split("/")
|
|
671
|
+
if (len(parts) != 4 or "/".join(parts[:3]).casefold() != ".tezgah/plans/open"
|
|
672
|
+
or not parts[3].casefold().endswith(".md")):
|
|
673
|
+
return False
|
|
674
|
+
for name, _, text in _plan_files(root, "open"):
|
|
675
|
+
if name.casefold() == parts[3].casefold():
|
|
676
|
+
return frontmatter(text).get("phase") in TASK_PHASES
|
|
677
|
+
return False
|
|
678
|
+
|
|
679
|
+
|
|
500
680
|
def match(rel, pattern):
|
|
501
681
|
"""Glob match for one allowlist pattern against a repo-relative posix path.
|
|
502
682
|
`**` crosses separators, `*` does not, everything else is literal."""
|
|
@@ -574,15 +754,67 @@ def _sibling_worktree(real, root):
|
|
|
574
754
|
return top if os.path.realpath(back) == os.path.join(top, ".git") else None
|
|
575
755
|
|
|
576
756
|
|
|
757
|
+
@contextlib.contextmanager
|
|
758
|
+
def plans_lock(path):
|
|
759
|
+
"""Hold the exclusive lock every plan record writer takes, on the `plans/`
|
|
760
|
+
directory above the plan file `path`, across its whole read-modify-write:
|
|
761
|
+
two writers in two checkouts lost one write in 40 rounds without it (plan
|
|
762
|
+
097, E7). The directory itself is locked, so no lock file is left for the
|
|
763
|
+
private repository's `add -A` to commit (review of plan 097, F6). Raises
|
|
764
|
+
OSError when the directory cannot be opened."""
|
|
765
|
+
try:
|
|
766
|
+
import fcntl
|
|
767
|
+
except ImportError:
|
|
768
|
+
# ponytail: Windows has no fcntl, so writers run unlocked there; the
|
|
769
|
+
# rename in _write still keeps every read whole, but two concurrent
|
|
770
|
+
# writers can lose one write. msvcrt.locking is the fuller version.
|
|
771
|
+
fcntl = None
|
|
772
|
+
if fcntl is None:
|
|
773
|
+
yield
|
|
774
|
+
return
|
|
775
|
+
fd = os.open(os.path.dirname(os.path.dirname(os.path.realpath(path))), os.O_RDONLY)
|
|
776
|
+
try:
|
|
777
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
778
|
+
yield
|
|
779
|
+
finally:
|
|
780
|
+
os.close(fd) # closing the descriptor releases the lock
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _write(path, text):
|
|
784
|
+
"""Replace the file at `path` with `text` whole: written to a temp file
|
|
785
|
+
beside it, then renamed over it, so a reader sees the old record or the new
|
|
786
|
+
one and never a truncated one - which read as no frontmatter, so no active
|
|
787
|
+
task, and opened the gate (29 torn reads in 40, plan 097 E7). Keeps the
|
|
788
|
+
file's mode. Raises OSError."""
|
|
789
|
+
import tempfile
|
|
790
|
+
path = os.path.realpath(path)
|
|
791
|
+
folder, name = os.path.split(path)
|
|
792
|
+
fd, tmp = tempfile.mkstemp(prefix="." + name + ".", suffix=".tmp", dir=folder)
|
|
793
|
+
try:
|
|
794
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
795
|
+
fh.write(text)
|
|
796
|
+
os.chmod(tmp, os.stat(path).st_mode & 0o7777)
|
|
797
|
+
os.replace(tmp, path)
|
|
798
|
+
except BaseException:
|
|
799
|
+
with contextlib.suppress(OSError):
|
|
800
|
+
os.unlink(tmp)
|
|
801
|
+
raise
|
|
802
|
+
|
|
803
|
+
|
|
577
804
|
def set_fields(path, **fields):
|
|
578
|
-
"""Rewrite these frontmatter keys in the plan file
|
|
805
|
+
"""Rewrite these frontmatter keys in the plan file: replace the
|
|
579
806
|
`key: value` line when present, insert it before the closing `---` when not.
|
|
580
807
|
`allowed_paths` takes a list and is written as a `- item` block, replacing an
|
|
581
808
|
existing block. `updated` is not special-cased here: the CLI passes today's
|
|
582
809
|
date under it, as it does for a research line's `created:`, so this module
|
|
583
|
-
needs no clock of its own.
|
|
584
|
-
|
|
585
|
-
|
|
810
|
+
needs no clock of its own. Under the plans lock, written whole (`_write`).
|
|
811
|
+
Raises OSError."""
|
|
812
|
+
with plans_lock(path):
|
|
813
|
+
_set_fields(path, fields)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _set_fields(path, fields):
|
|
817
|
+
lines = _lines(path)
|
|
586
818
|
if not lines or lines[0].strip() != "---":
|
|
587
819
|
lines = ["---", "---"] + lines
|
|
588
820
|
close = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
|
|
@@ -603,8 +835,17 @@ def set_fields(path, **fields):
|
|
|
603
835
|
end += 1
|
|
604
836
|
block[head:end] = fresh
|
|
605
837
|
lines[1:close] = block
|
|
606
|
-
|
|
607
|
-
|
|
838
|
+
_write(path, "\n".join(lines))
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def close_record(path, target, **fields):
|
|
842
|
+
"""Write `fields` into the plan at `path` and move it to `target`, under
|
|
843
|
+
the plans lock: a tick racing the close then fails on the moved file instead
|
|
844
|
+
of writing it back into `open/`. Raises OSError."""
|
|
845
|
+
with plans_lock(path):
|
|
846
|
+
_set_fields(path, fields)
|
|
847
|
+
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
848
|
+
os.replace(path, target)
|
|
608
849
|
|
|
609
850
|
|
|
610
851
|
def _field_lines(key, value):
|
|
@@ -624,36 +865,41 @@ def _lines(path):
|
|
|
624
865
|
return fh.read().split("\n")
|
|
625
866
|
|
|
626
867
|
|
|
627
|
-
def
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
868
|
+
def set_box(path, n, checked):
|
|
869
|
+
"""Set the `- [ ]`/`- [x]` box of Acceptance item `n` (1-based, as `tick`
|
|
870
|
+
counts) and return the item, or None when the plan has no item `n`. The
|
|
871
|
+
item's line is found under the plans lock: read before it, a frontmatter
|
|
872
|
+
line another writer inserted moved the box ticked (review of plan 097,
|
|
873
|
+
F5). Raises OSError."""
|
|
874
|
+
with plans_lock(path):
|
|
875
|
+
lines = _lines(path)
|
|
876
|
+
items = acceptance_items("\n".join(lines))
|
|
877
|
+
if not 1 <= n <= len(items):
|
|
878
|
+
return None
|
|
879
|
+
line = items[n - 1]["line"] - 1
|
|
880
|
+
lines[line] = re.sub(r"- \[[ xX]\]", "- [x]" if checked else "- [ ]",
|
|
881
|
+
lines[line], count=1)
|
|
882
|
+
_write(path, "\n".join(lines))
|
|
883
|
+
return items[n - 1]
|
|
639
884
|
|
|
640
885
|
|
|
641
886
|
def set_section(path, heading, body):
|
|
642
887
|
"""Replace the body of the last `## <heading>` section - the newest, since
|
|
643
|
-
a plan appends a dated `## State (...)` - with `body` and one blank line
|
|
644
|
-
Returns False, writing nothing, when the plan has no
|
|
645
|
-
OSError."""
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
888
|
+
a plan appends a dated `## State (...)` - with `body` and one blank line,
|
|
889
|
+
under the plans lock. Returns False, writing nothing, when the plan has no
|
|
890
|
+
such section. Raises OSError."""
|
|
891
|
+
with plans_lock(path):
|
|
892
|
+
lines = _lines(path)
|
|
893
|
+
heads = [i for i, line in enumerate(lines)
|
|
894
|
+
if re.match(r"## %s(\s|$)" % re.escape(heading), line.strip())]
|
|
895
|
+
if not heads:
|
|
896
|
+
return False
|
|
897
|
+
start = heads[-1] + 1
|
|
898
|
+
end = next((i for i in range(start, len(lines))
|
|
899
|
+
if lines[i].lstrip().startswith("## ")), len(lines))
|
|
900
|
+
lines[start:end] = body.strip("\n").split("\n") + [""]
|
|
901
|
+
_write(path, "\n".join(lines))
|
|
902
|
+
return True
|
|
657
903
|
|
|
658
904
|
|
|
659
905
|
# ---------------------------------------------------------------- slice journal
|
|
@@ -1072,6 +1318,10 @@ def worktree_clash(root, tool, inp, parent=""):
|
|
|
1072
1318
|
return SHARED_WORKTREE % (", ".join(clash[0][1]), clash[0][0]) if clash else None
|
|
1073
1319
|
|
|
1074
1320
|
|
|
1321
|
+
# `plan 093` in a brief: the plan a slice works on when its checkout says none
|
|
1322
|
+
_BRIEF_PLAN = re.compile(r"(?i)\bplan\s+(\d{3})\b")
|
|
1323
|
+
|
|
1324
|
+
|
|
1075
1325
|
def open_slice(root, sid, *, kind="agent", host="", agent="", brief="", parent="",
|
|
1076
1326
|
cwd="", repo="", task="", child=""):
|
|
1077
1327
|
"""Open (or re-open) the record `sid` and return it. The worktree and branch
|
|
@@ -1121,9 +1371,13 @@ def open_slice(root, sid, *, kind="agent", host="", agent="", brief="", parent="
|
|
|
1121
1371
|
# one glob over every omp project at open, so `pending` - run on every
|
|
1122
1372
|
# prompt and Stop - never globs (40 ms on 1393 project dirs, measured)
|
|
1123
1373
|
rec["transcript_dir"] = _omp_session_dir(rec["parent"]) or ""
|
|
1124
|
-
# the
|
|
1125
|
-
#
|
|
1126
|
-
|
|
1374
|
+
# the slice's own plan: the review guard reads its own plan's records only.
|
|
1375
|
+
# Its worktree's plan branch, else the plan its task or shared brief names,
|
|
1376
|
+
# else the tag a re-open already carries, else the task the main checkout
|
|
1377
|
+
# reads - with two plans running that is only one of them
|
|
1378
|
+
named = _BRIEF_PLAN.search(own) or _BRIEF_PLAN.search(brief or "")
|
|
1379
|
+
rec["plan"] = (plan_of_branch(rec.get("branch")) or (named and named.group(1))
|
|
1380
|
+
or rec.get("plan") or (active(main, main) or {}).get("id") or "")
|
|
1127
1381
|
rec.update(base=_git(main, "rev-parse", "HEAD") or "", status="open",
|
|
1128
1382
|
failure="", updated=now)
|
|
1129
1383
|
return _write_record(root, rec)
|
|
@@ -1264,12 +1518,10 @@ _ABS_PATH = re.compile(r"(?<![\w.~/-])/[^\s'\"`<>()\[\]{},;|*?]+")
|
|
|
1264
1518
|
|
|
1265
1519
|
def main_repo(path):
|
|
1266
1520
|
"""The main checkout of the repository holding `path`, else None. A path
|
|
1267
|
-
under a repository's own `.tezgah`
|
|
1268
|
-
|
|
1521
|
+
under a repository's own `.tezgah` belongs to the repository around it
|
|
1522
|
+
(`_toplevel`), and a worktree to its main checkout."""
|
|
1269
1523
|
from tezgah_paths import _toplevel
|
|
1270
1524
|
top = _toplevel(os.path.realpath(path))
|
|
1271
|
-
while top and os.path.basename(top) == ".tezgah":
|
|
1272
|
-
top = _toplevel(os.path.dirname(top))
|
|
1273
1525
|
return _journal_root(top) if top else None
|
|
1274
1526
|
|
|
1275
1527
|
|
package/hooks/tezgah_taste.py
CHANGED
|
@@ -27,7 +27,8 @@ import time
|
|
|
27
27
|
|
|
28
28
|
import tezgah_integrity as ti
|
|
29
29
|
import tezgah_paths
|
|
30
|
-
from tezgah_paths import _toplevel, armed, root_for,
|
|
30
|
+
from tezgah_paths import (_linked_main_at, _toplevel, armed, root_for,
|
|
31
|
+
workspace_from_repo)
|
|
31
32
|
|
|
32
33
|
ARM = "taste-on"
|
|
33
34
|
LEARN_EVERY = 3600 # seconds between two background `learn` starts per repo
|
|
@@ -46,7 +47,8 @@ WHOLE_KEYS = ("content", "file_text", "text", "new_source")
|
|
|
46
47
|
|
|
47
48
|
def enabled(cwd):
|
|
48
49
|
"""The git root taste rows for `cwd` go to, or None when capture is off
|
|
49
|
-
there
|
|
50
|
+
there: a linked worktree's rows go to its main checkout, the one `.tezgah`
|
|
51
|
+
of the repository. The marker is read first, so the default costs two stats."""
|
|
50
52
|
try:
|
|
51
53
|
if not cwd or not armed(ARM):
|
|
52
54
|
return None
|
|
@@ -54,6 +56,7 @@ def enabled(cwd):
|
|
|
54
56
|
if not root_for(real):
|
|
55
57
|
return None
|
|
56
58
|
top = _toplevel(real)
|
|
59
|
+
top = top and (_linked_main_at(top) or top)
|
|
57
60
|
if not top or not os.path.isdir(os.path.join(top, ".tezgah")):
|
|
58
61
|
return None
|
|
59
62
|
from tezgah_context import repo_marks # imports this module's callers
|
|
@@ -73,10 +76,11 @@ def _cut(text, limit):
|
|
|
73
76
|
|
|
74
77
|
|
|
75
78
|
def _rel(root, path, cwd):
|
|
76
|
-
"""`path` relative to
|
|
79
|
+
"""`path` relative to the checkout holding `cwd` (a worktree's paths read
|
|
80
|
+
the same as main's), or None when it resolves outside it."""
|
|
77
81
|
apath = os.path.realpath(
|
|
78
82
|
str(path) if os.path.isabs(str(path)) else os.path.join(cwd, str(path)))
|
|
79
|
-
rel = os.path.relpath(apath, root)
|
|
83
|
+
rel = os.path.relpath(apath, _toplevel(os.path.realpath(cwd)) or root)
|
|
80
84
|
return None if rel == ".." or rel.startswith(".." + os.sep) else rel
|
|
81
85
|
|
|
82
86
|
|
package/hosts/codex/hook.py
CHANGED
|
@@ -31,7 +31,7 @@ try:
|
|
|
31
31
|
shell_kind)
|
|
32
32
|
from tezgah_gate import cross_project_note, decision, graph_advice # noqa: E402
|
|
33
33
|
from tezgah_integrity import ( # noqa: E402
|
|
34
|
-
SUBAGENT_CHANNEL, bind_session, changed_files_notice, note_tool, ran_nothing,
|
|
34
|
+
SUBAGENT_CHANNEL, bind_session, changed_files_notice, note, note_tool, ran_nothing,
|
|
35
35
|
report_bytes, stop_reason, untrusted_source)
|
|
36
36
|
from tezgah_paths import HOST_DIRS, off, root_for # noqa: E402
|
|
37
37
|
from tezgah_taste import write_note # noqa: E402
|
|
@@ -108,7 +108,16 @@ def gate_reason(payload, cwd, session_id):
|
|
|
108
108
|
inp = payload.get("tool_input")
|
|
109
109
|
if not isinstance(inp, dict):
|
|
110
110
|
inp = {}
|
|
111
|
-
|
|
111
|
+
# a subagent's calls carry its `agent_id` (the child thread id; optional in
|
|
112
|
+
# pre-tool-use.command.input.schema.json): its repeat ceilings are its own
|
|
113
|
+
return decision(gate_name(name), inp, cwd, session_id, agent=subagent_id(payload))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def subagent_id(payload):
|
|
117
|
+
"""The child thread id Codex puts on a subagent's hook input, else None (a
|
|
118
|
+
root-agent call; codex-rs/hooks: "Add subagent identity to hook inputs")."""
|
|
119
|
+
aid = payload.get("agent_id")
|
|
120
|
+
return aid if isinstance(aid, str) and aid else None
|
|
112
121
|
|
|
113
122
|
|
|
114
123
|
def verify_outcome(payload):
|
|
@@ -196,7 +205,11 @@ def main():
|
|
|
196
205
|
out_bytes=(report_bytes(result)
|
|
197
206
|
if untrusted_source(tool, inp) == SUBAGENT_CHANNEL
|
|
198
207
|
else result_size(result)),
|
|
199
|
-
empty_run=ran_nothing(result)
|
|
208
|
+
empty_run=ran_nothing(result),
|
|
209
|
+
# the child's rows land in the root session's ledger (Codex's
|
|
210
|
+
# session_id is the root thread's); the route report reads them
|
|
211
|
+
# back by this id
|
|
212
|
+
agent=subagent_id(payload))
|
|
200
213
|
# The gate's graph line for a code-symbol search rides the same
|
|
201
214
|
# non-blocking channel (tezgah_gate.graph_advice): a Codex PreToolUse
|
|
202
215
|
# carries a deny and nothing else.
|
|
@@ -220,6 +233,13 @@ def main():
|
|
|
220
233
|
return
|
|
221
234
|
if event == "SubagentStart":
|
|
222
235
|
safe(session_id, record, session_id, "orch")
|
|
236
|
+
# the child's typing row in the root ledger: SubagentStart requires
|
|
237
|
+
# agent_id and agent_type (codex-rs/hooks/schema/generated/
|
|
238
|
+
# subagent-start.command.input.schema.json)
|
|
239
|
+
kind = payload.get("agent_type")
|
|
240
|
+
if subagent_id(payload) and isinstance(kind, str) and root_for(cwd):
|
|
241
|
+
safe(session_id, note, session_id, "subagent_start", kind,
|
|
242
|
+
agent=subagent_id(payload))
|
|
223
243
|
if event == "Stop":
|
|
224
244
|
out = {}
|
|
225
245
|
# Codex's stop.input carries the same fields Claude's does
|
package/hosts/cursor/hook.py
CHANGED
|
@@ -388,6 +388,17 @@ def dispatch(payload):
|
|
|
388
388
|
else:
|
|
389
389
|
record(session_id, "orch")
|
|
390
390
|
out = {"permission": "allow"}
|
|
391
|
+
# The child's ledger opens with its parent, as on omp and opencode:
|
|
392
|
+
# a Cursor subagent runs under its own conversation id (observed with
|
|
393
|
+
# cursor-agent 2026.10.01), and this event names it `subagent_id`
|
|
394
|
+
# beside `parent_conversation_id` (Cursor hooks docs, subagentStart).
|
|
395
|
+
# `host` marks the row whose id the report has not seen carried by
|
|
396
|
+
# the child's own calls (tezgah_models.route_report).
|
|
397
|
+
child, parent = payload.get("subagent_id"), payload.get("parent_conversation_id")
|
|
398
|
+
if isinstance(child, str) and child and isinstance(parent, str) and parent \
|
|
399
|
+
and under(cwd):
|
|
400
|
+
safe(child, note, child, "spawned", "", parent=parent,
|
|
401
|
+
agent=str(payload.get("subagent_type") or "") or None, host="cursor")
|
|
391
402
|
# The delegate is a fresh context that must know the rules exist, so
|
|
392
403
|
# it gets the same short brief every other host hands a subagent.
|
|
393
404
|
# Cursor documents no context field on subagentStart (its output is
|