@r1z4x/tezgah 1.5.0 → 1.6.0
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 +96 -1
- package/MANIFEST +6 -1
- package/README.md +6 -4
- package/bin/consult +13 -10
- package/bin/tezgah-docs +1 -1
- package/bin/tezgah-research +53 -3
- package/bin/tezgah-route +8 -5
- package/bin/tezgah-setup +45 -25
- package/bin/tezgah-task +194 -41
- package/docs/architecture.md +1 -1
- package/docs/citations-baseline.json +1 -1
- package/docs/clarity-baseline.json +4 -4
- package/docs/contract.md +4 -1
- package/docs/feature-audit.md +24 -3
- package/docs/gate.md +10 -11
- package/docs/hosts.md +9 -0
- package/docs/index.json +21 -7
- package/docs/judge.md +3 -1
- package/docs/layers.md +1 -1
- package/docs/models.md +17 -7
- package/docs/operations.md +0 -1
- package/docs/research.md +46 -0
- package/docs/skills.md +23 -5
- package/docs/testing.md +1 -1
- package/hooks/tezgah_agents.py +130 -96
- package/hooks/tezgah_components.py +2 -1
- package/hooks/tezgah_context.py +67 -15
- package/hooks/tezgah_gate.py +62 -76
- package/hooks/tezgah_judge.py +15 -8
- package/hooks/tezgah_models.py +50 -3
- package/hooks/tezgah_paths.py +26 -11
- package/hooks/tezgah_policy.py +47 -19
- package/hooks/tezgah_product.py +107 -0
- package/hooks/tezgah_research.py +3 -3
- package/hooks/tezgah_skill_pick.py +58 -17
- package/hooks/tezgah_task.py +16 -8
- package/hosts/opencode/plugins/tezgah.js +17 -37
- package/package.json +1 -2
- package/skills/analyze-app/SKILL.md +57 -0
- package/skills/design-contract/EVALS.md +24 -1
- package/skills/design-contract/SKILL.md +17 -1
- package/skills/design-contract/data-views.md +86 -0
- package/skills/feature-audit/SKILL.md +32 -3
- package/skills/plan-add/EVALS.md +8 -8
- package/skills/plan-add/SKILL.md +20 -21
- package/skills/plan-sync/EVALS.md +2 -2
- package/skills/plan-sync/SKILL.md +11 -7
- package/skills/pm-frameworks/SKILL.md +6 -0
- package/skills/product-analysis/EVALS.md +23 -1
- package/skills/product-analysis/SKILL.md +50 -11
- package/skills/product-flow/EVALS.md +81 -0
- package/skills/product-flow/SKILL.md +91 -0
- package/skills/research/SKILL.md +10 -1
- package/skills/tezgah-contract/SKILL.md +67 -41
- package/agents/tezgah-reviewer.md +0 -79
package/bin/tezgah-task
CHANGED
|
@@ -3,18 +3,19 @@
|
|
|
3
3
|
|
|
4
4
|
tezgah-task start <NNN|path> --phase P [--allow GLOB ... | --any-path] activate a plan
|
|
5
5
|
tezgah-task phase P [--any-path] change the active task's phase
|
|
6
|
-
tezgah-task allow GLOB ... | --any-path
|
|
6
|
+
tezgah-task allow GLOB ... | --any-path --why REASON replace the allowlist, recording why
|
|
7
7
|
tezgah-task stop deactivate (clear phase)
|
|
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
|
-
tezgah-task close <NNN|path> done|discarded
|
|
10
|
+
tezgah-task close <NNN|path> done|discarded [--resolved TEXT] move a plan to .tezgah/plans/done, deactivate it, remove merged worktrees
|
|
11
11
|
|
|
12
12
|
The record is the plan file itself: `phase:` activates it, `allowed_paths:` is
|
|
13
13
|
the scope a write has to stay inside, and at most one open plan carries a phase
|
|
14
|
-
because `start` clears the others.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
because `start` clears the others. The orchestrating session runs every command
|
|
15
|
+
here itself as the work moves - the lifecycle is never handed back to the user -
|
|
16
|
+
and this CLI is the record's only writer: the gate refuses a write to the plan
|
|
17
|
+
file, because the checks below are made here and a retyped frontmatter would
|
|
18
|
+
skip them. `phase implementation` (or `verification`) is what lets a write
|
|
18
19
|
through, so the phase changes when the work reaches that point and not before.
|
|
19
20
|
|
|
20
21
|
The allowlist is the plan's own: plan-add writes `allowed_paths:` when it
|
|
@@ -22,16 +23,24 @@ creates the plan, and `start` without `--allow` keeps it. A writing phase with
|
|
|
22
23
|
no allowlist is refused unless `--any-path` says so explicitly, and the record
|
|
23
24
|
keeps where its scope came from (`allowed_from:`), which `status` prints.
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
The verification phase asks for one review of the plan's whole diff: `review`
|
|
27
|
+
records its verdict as
|
|
27
28
|
`review: <reviewer> <verdict> <date>` together with `reviewed_sha:` (the
|
|
28
29
|
project's HEAD it read) and `review_round:`, so the next round reads only
|
|
29
30
|
`git diff <reviewed_sha>..HEAD`, and refuses a third round: the budget is two
|
|
30
31
|
rounds per plan; `close NNN done` refuses a plan whose review is missing or not
|
|
31
|
-
`approve
|
|
32
|
+
`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
|
|
34
|
+
removes each linked worktree whose branch has landed in HEAD, `main`, `master`
|
|
35
|
+
or the remote's default branch (an ancestor of it, or a squash whose merge
|
|
36
|
+
would change nothing) and which holds nothing else (no uncommitted or unknown
|
|
37
|
+
ignored file, no `.tezgah` data, a branch someone committed on, no Orca agent
|
|
38
|
+
still at work; Orca down keeps them all); every worktree it keeps is printed
|
|
39
|
+
with the reason.
|
|
32
40
|
|
|
33
41
|
Two things the plan owes are asked here, at the only moment they can still be
|
|
34
|
-
fixed - the phase move - because
|
|
42
|
+
fixed - the phase move - because this CLI is the record's only writer and the
|
|
43
|
+
refusal is
|
|
35
44
|
this CLI's: a writing phase needs every Acceptance item to name the command
|
|
36
45
|
proving it (or say `unverifiable` and why), since the report CI runs afterwards
|
|
37
46
|
refuses an open plan on any one item that does neither, through the same
|
|
@@ -161,11 +170,13 @@ def allow_source(text):
|
|
|
161
170
|
`--allow`/`allow` (`allowed_from:` names which), or an explicit
|
|
162
171
|
`--any-path`. No list and no `--any-path` is what a writing phase refuses."""
|
|
163
172
|
allow = tt.allowed_paths(text)
|
|
164
|
-
|
|
173
|
+
fields = tt.frontmatter(text)
|
|
174
|
+
source = fields.get("allowed_from") or ""
|
|
175
|
+
why = (" (why: %s)" % fields["allowed_why"]) if fields.get("allowed_why") else ""
|
|
165
176
|
if allow:
|
|
166
|
-
return allow, ("from tezgah-task %s" % source) if source else "from the plan"
|
|
177
|
+
return allow, (("from tezgah-task %s" % source) + why) if source else "from the plan"
|
|
167
178
|
if source == ANY_PATH:
|
|
168
|
-
return allow, "any path in the repo (--any-path)"
|
|
179
|
+
return allow, "any path in the repo (--any-path)" + why
|
|
169
180
|
return allow, "any path in the repo (no allowlist)"
|
|
170
181
|
|
|
171
182
|
|
|
@@ -285,9 +296,9 @@ def phase_problems(phase, text, path):
|
|
|
285
296
|
Both are read from the plan this CLI is about to move, through the same
|
|
286
297
|
readers the CI report uses, so the phase the gate then holds a write to is a
|
|
287
298
|
phase the plan can actually be checked in. They live here, at the phase move,
|
|
288
|
-
and not in the gate: the phase has one writer, this command
|
|
289
|
-
|
|
290
|
-
|
|
299
|
+
and not in the gate: the phase has one writer, this command - a gate rule
|
|
300
|
+
would have to re-read the same body on every write to ask a question whose
|
|
301
|
+
answer changes only here."""
|
|
291
302
|
out = []
|
|
292
303
|
if phase in tt.WRITE_PHASES:
|
|
293
304
|
items = tt.acceptance_gap(text)
|
|
@@ -329,8 +340,8 @@ def verification_steps(text, path):
|
|
|
329
340
|
the budget `review` enforces."""
|
|
330
341
|
fields = tt.frontmatter(text)
|
|
331
342
|
ident = fields.get("id") or os.path.basename(path).split("-")[0]
|
|
332
|
-
# every item, not only the ones the command reader recognises:
|
|
333
|
-
#
|
|
343
|
+
# every item, not only the ones the command reader recognises: a bare
|
|
344
|
+
# `pytest` is a command it does not read as one, and must not vanish here
|
|
334
345
|
items = ["%s%s" % (i["text"], "" if i["state"] == "checkable" else
|
|
335
346
|
" [%s]" % ("unverifiable" if i["state"] == "unverifiable"
|
|
336
347
|
else "names no command"))
|
|
@@ -356,9 +367,10 @@ def verification_steps(text, path):
|
|
|
356
367
|
return
|
|
357
368
|
if rounds >= REVIEW_ROUNDS:
|
|
358
369
|
print(" 2. the %d-round budget is spent: `tezgah-task review` refuses a "
|
|
359
|
-
"third round.
|
|
360
|
-
"
|
|
361
|
-
"under the plan's
|
|
370
|
+
"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)
|
|
362
374
|
return
|
|
363
375
|
if rounds:
|
|
364
376
|
print(" 2. review round 2 (the last): each confirmed critical or major "
|
|
@@ -443,10 +455,10 @@ def cmd_start(args):
|
|
|
443
455
|
return problems([problem])
|
|
444
456
|
text = read(path)
|
|
445
457
|
fields = {"phase": phase, "updated": today()}
|
|
446
|
-
if allow:
|
|
447
|
-
fields.update(allowed_paths=allow, allowed_from="--allow")
|
|
448
|
-
|
|
449
|
-
|
|
458
|
+
if allow or any_path:
|
|
459
|
+
fields.update(allowed_paths=allow, allowed_from="--allow" if allow else ANY_PATH)
|
|
460
|
+
if tt.frontmatter(text).get("allowed_why"):
|
|
461
|
+
fields["allowed_why"] = "" # the reason belonged to the scope replaced
|
|
450
462
|
else:
|
|
451
463
|
# the plan's own scope, as plan-add wrote it (or an earlier --allow)
|
|
452
464
|
allow = tt.allowed_paths(text)
|
|
@@ -489,6 +501,8 @@ def cmd_phase(args):
|
|
|
489
501
|
fields = {"phase": phase, "updated": today()}
|
|
490
502
|
if any_path:
|
|
491
503
|
fields.update(allowed_paths=[], allowed_from=ANY_PATH)
|
|
504
|
+
if tt.frontmatter(text).get("allowed_why"):
|
|
505
|
+
fields["allowed_why"] = ""
|
|
492
506
|
granted = (any_path or tt.frontmatter(text).get("allowed_from")
|
|
493
507
|
== ANY_PATH)
|
|
494
508
|
bad = (scope_problem(phase, task["allowed_paths"], granted)
|
|
@@ -503,6 +517,13 @@ def cmd_phase(args):
|
|
|
503
517
|
|
|
504
518
|
|
|
505
519
|
def cmd_allow(args):
|
|
520
|
+
# the scope is the session's to widen, so the reason is the price: a widening
|
|
521
|
+
# nobody can read back later is the unbounded task this record exists to stop
|
|
522
|
+
why = ""
|
|
523
|
+
if "--why" in args:
|
|
524
|
+
i = args.index("--why")
|
|
525
|
+
why = " ".join((args[i + 1:i + 2] or [""])[0].split())
|
|
526
|
+
args = args[:i] + args[i + 2:]
|
|
506
527
|
any_path = ANY_PATH in args
|
|
507
528
|
args = [a for a in args if a != ANY_PATH]
|
|
508
529
|
for arg in args:
|
|
@@ -510,6 +531,8 @@ def cmd_allow(args):
|
|
|
510
531
|
return misuse("allow takes globs, not %r" % arg)
|
|
511
532
|
if bool(args) == any_path:
|
|
512
533
|
return misuse("allow takes globs, or --any-path alone to lift the scope")
|
|
534
|
+
if not why:
|
|
535
|
+
return misuse("allow takes --why \"<what in the plan reaches these paths>\"")
|
|
513
536
|
task = active_here(repo_root(os.getcwd()))
|
|
514
537
|
if not task:
|
|
515
538
|
return problems(["no active task to widen: start one with "
|
|
@@ -519,7 +542,8 @@ def cmd_allow(args):
|
|
|
519
542
|
# the way back out of a scope that turned out to be too narrow. A flag and
|
|
520
543
|
# not an empty call, so lifting the scope is always said out loud.
|
|
521
544
|
bad = write_fields(task["path"], allowed_paths=args, updated=today(),
|
|
522
|
-
allowed_from=ANY_PATH if any_path else "allow"
|
|
545
|
+
allowed_from=ANY_PATH if any_path else "allow",
|
|
546
|
+
allowed_why=why)
|
|
523
547
|
if bad:
|
|
524
548
|
return problems(bad)
|
|
525
549
|
return show(task["path"])
|
|
@@ -546,13 +570,14 @@ def review_verdict(text):
|
|
|
546
570
|
|
|
547
571
|
|
|
548
572
|
def budget_spent(path, ident):
|
|
549
|
-
"""The refusal once REVIEW_ROUNDS rounds are recorded:
|
|
550
|
-
|
|
551
|
-
return ("%s has spent its %d-round review budget: never a third round.
|
|
552
|
-
"critical or major finding still confirmed
|
|
553
|
-
"
|
|
554
|
-
"
|
|
555
|
-
"
|
|
573
|
+
"""The refusal once REVIEW_ROUNDS rounds are recorded: never a third round,
|
|
574
|
+
and never a hand-off - the session settles what is left itself."""
|
|
575
|
+
return ("%s has spent its %d-round review budget: never a third round. Fix a "
|
|
576
|
+
"critical or major finding still confirmed yourself and prove it with "
|
|
577
|
+
"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))
|
|
556
581
|
|
|
557
582
|
|
|
558
583
|
def cmd_review(args):
|
|
@@ -583,8 +608,15 @@ def cmd_review(args):
|
|
|
583
608
|
|
|
584
609
|
|
|
585
610
|
def cmd_close(args):
|
|
611
|
+
resolved = ""
|
|
612
|
+
if "--resolved" in args:
|
|
613
|
+
i = args.index("--resolved")
|
|
614
|
+
resolved = " ".join((args[i + 1:i + 2] or [""])[0].split())
|
|
615
|
+
args = args[:i] + args[i + 2:]
|
|
616
|
+
if not resolved:
|
|
617
|
+
return misuse("--resolved takes the fix and the test that proves it")
|
|
586
618
|
if len(args) != 2 or args[1] not in ("done", "discarded"):
|
|
587
|
-
return misuse("close takes <NNN|path> done|discarded")
|
|
619
|
+
return misuse("close takes <NNN|path> done|discarded [--resolved TEXT]")
|
|
588
620
|
root = repo_root(os.getcwd())
|
|
589
621
|
path, problem = plan_path(root, args[0])
|
|
590
622
|
if problem:
|
|
@@ -593,18 +625,26 @@ def cmd_close(args):
|
|
|
593
625
|
return problems(["%s is not under %s" % (path, open_dir(root))])
|
|
594
626
|
text = read(path)
|
|
595
627
|
ident = os.path.basename(path).split("-")[0]
|
|
628
|
+
# closing the active task deactivates it in the same write: the plan leaves
|
|
629
|
+
# open/ anyway, and a separate `stop` first was one more step for nobody
|
|
630
|
+
fields = {"status": args[1], "updated": today()}
|
|
596
631
|
if tt.frontmatter(text).get("phase"):
|
|
597
|
-
|
|
598
|
-
"(`tezgah-task stop`)" % path])
|
|
632
|
+
fields["phase"] = ""
|
|
599
633
|
if args[1] == "done" and review_verdict(text) != "approve":
|
|
600
634
|
prior = tt.frontmatter(text).get("review_round") or ""
|
|
601
|
-
|
|
635
|
+
spent = prior.isdigit() and int(prior) >= REVIEW_ROUNDS
|
|
636
|
+
if spent and resolved:
|
|
637
|
+
# the budget is spent and the session fixed what was left: the fix
|
|
638
|
+
# and its proof go on the record a later reader rejects it from
|
|
639
|
+
fields["resolved"] = "%s %s" % (resolved, today())
|
|
640
|
+
elif spent:
|
|
602
641
|
return problems([budget_spent(path, ident)])
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
642
|
+
else:
|
|
643
|
+
return problems(["%s records no approving review: a fresh reviewer reads "
|
|
644
|
+
"the diff, then `tezgah-task review %s <reviewer> approve`"
|
|
645
|
+
% (path, ident)])
|
|
606
646
|
target = os.path.join(root, ".tezgah", "plans", "done", os.path.basename(path))
|
|
607
|
-
bad = write_fields(path,
|
|
647
|
+
bad = write_fields(path, **fields)
|
|
608
648
|
if bad:
|
|
609
649
|
return problems(bad)
|
|
610
650
|
try:
|
|
@@ -613,9 +653,122 @@ def cmd_close(args):
|
|
|
613
653
|
except OSError as exc:
|
|
614
654
|
return problems(["cannot move %s: %s" % (path, exc)])
|
|
615
655
|
print("moved %s -> %s" % (path, target))
|
|
656
|
+
for line in prune_worktrees(root):
|
|
657
|
+
print(line)
|
|
616
658
|
return 0
|
|
617
659
|
|
|
618
660
|
|
|
661
|
+
# Ignored output a slice regenerates on its own. Any other ignored path - an
|
|
662
|
+
# `.env`, a local database, a dataset - may be the only copy, so it keeps the
|
|
663
|
+
# worktree; a list rather than `--ignored` alone because every checkout that ran
|
|
664
|
+
# a test holds a cache.
|
|
665
|
+
REGENERABLE = frozenset(("__pycache__", ".pytest_cache", ".ruff_cache",
|
|
666
|
+
".mypy_cache", "node_modules", ".venv", "venv",
|
|
667
|
+
".DS_Store"))
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _landed(root, branch, base):
|
|
671
|
+
"""True when `branch`'s work is in `base`: an ancestor of it, or - for a
|
|
672
|
+
squash or rebase merge, which leaves no ancestry - a branch whose merge into
|
|
673
|
+
`base` would change nothing. `merge-tree --write-tree` needs git 2.38; an
|
|
674
|
+
older git answers None, which reads as not landed and keeps the worktree."""
|
|
675
|
+
if git_out(root, "merge-base", "--is-ancestor", branch, base) is not None:
|
|
676
|
+
return True
|
|
677
|
+
# a branch whose commits cancel out (X then revert X) merges to base's tree
|
|
678
|
+
# too, while its commits are in base nowhere: it needs a net change to count
|
|
679
|
+
fork = git_out(root, "merge-base", base, branch)
|
|
680
|
+
if not fork or git_out(root, "diff", "--quiet", fork, branch) is not None:
|
|
681
|
+
return False
|
|
682
|
+
merged = (git_out(root, "merge-tree", "--write-tree", base, branch) or "").split("\n")[0]
|
|
683
|
+
return bool(merged) and merged == git_out(root, "rev-parse", base + "^{tree}")
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _keeps(root, path, branch, bases, agents):
|
|
687
|
+
"""Why the linked worktree `path` must stay, or None when its work is all in
|
|
688
|
+
`bases` and it holds nothing else. Every question git cannot answer keeps it."""
|
|
689
|
+
if not branch:
|
|
690
|
+
return "detached HEAD"
|
|
691
|
+
# -unormal: a `status.showUntrackedFiles=no` config would hide both lists
|
|
692
|
+
if git_out(path, "status", "--porcelain", "-unormal") != "":
|
|
693
|
+
return "uncommitted changes"
|
|
694
|
+
for line in (git_out(path, "status", "--porcelain", "-unormal", "--ignored") or "").splitlines():
|
|
695
|
+
rel = line[3:].strip('"').rstrip("/")
|
|
696
|
+
if rel != ".tezgah" and not REGENERABLE & set(rel.split("/")):
|
|
697
|
+
return "ignored file %s may be the only copy" % rel
|
|
698
|
+
ws = os.path.join(path, ".tezgah")
|
|
699
|
+
if os.path.isdir(ws):
|
|
700
|
+
if set(os.listdir(ws)) - {".git"}:
|
|
701
|
+
return "its .tezgah holds data"
|
|
702
|
+
if os.path.isdir(os.path.join(ws, ".git")) and git_out(ws, "rev-parse", "-q", "--verify", "HEAD"):
|
|
703
|
+
return "its .tezgah has commits"
|
|
704
|
+
if not any(_landed(root, branch, b) for b in bases):
|
|
705
|
+
return "%s is not merged into %s" % (branch, " or ".join(bases))
|
|
706
|
+
log = git_out(root, "reflog", "show", "--format=%gs", "refs/heads/" + branch) or ""
|
|
707
|
+
if not any(entry.startswith(("commit:", "commit (amend):", "commit (initial):"))
|
|
708
|
+
for entry in log.splitlines()):
|
|
709
|
+
# a branch nobody committed on is a slice that has not started - even
|
|
710
|
+
# one fast-forwarded to main, or one that only cherry-picked or merged
|
|
711
|
+
# main in - not one whose work landed
|
|
712
|
+
return "%s has no commit of its own yet" % branch
|
|
713
|
+
busy = [a.get("state") for a in agents if a.get("state") != "done"]
|
|
714
|
+
if busy:
|
|
715
|
+
return "an agent there is %s" % busy[0]
|
|
716
|
+
return None
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def prune_worktrees(root):
|
|
720
|
+
"""Remove every linked worktree of the repository whose branch has landed and
|
|
721
|
+
which holds nothing else, so a closed plan leaves no checkout behind; one line
|
|
722
|
+
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."""
|
|
727
|
+
import tezgah_orca
|
|
728
|
+
from tezgah_paths import worktrees
|
|
729
|
+
found = worktrees(root)
|
|
730
|
+
here = os.path.realpath(root)
|
|
731
|
+
linked = [p for p in found[1:] if p != here]
|
|
732
|
+
if not linked:
|
|
733
|
+
return []
|
|
734
|
+
ps = tezgah_orca.run(["worktree", "ps", "--limit", "1000"])
|
|
735
|
+
rows = ps.get("worktrees") if isinstance(ps, dict) else None
|
|
736
|
+
if not isinstance(rows, list) and tezgah_orca.cli():
|
|
737
|
+
# down, or an answer in a shape this reader does not know: either way
|
|
738
|
+
# its agents cannot be checked
|
|
739
|
+
return ["worktree kept: %s (Orca did not answer, so its agents cannot be "
|
|
740
|
+
"checked; run close again once `orca worktree ps` answers)" % p
|
|
741
|
+
for p in linked]
|
|
742
|
+
orca = {os.path.realpath(r.get("path") or ""): r for r in rows or []
|
|
743
|
+
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)]
|
|
748
|
+
out = []
|
|
749
|
+
for path in linked:
|
|
750
|
+
branch = git_out(path, "symbolic-ref", "-q", "--short", "HEAD")
|
|
751
|
+
try:
|
|
752
|
+
why = _keeps(root, path, branch, bases,
|
|
753
|
+
orca.get(path, {}).get("agents") or [])
|
|
754
|
+
except OSError as exc:
|
|
755
|
+
why = "cannot read it: %s" % exc
|
|
756
|
+
if why is None:
|
|
757
|
+
if path in orca:
|
|
758
|
+
gone = tezgah_orca.run(["worktree", "rm", "--worktree", "path:" + path],
|
|
759
|
+
timeout=60) is not None
|
|
760
|
+
how = "orca worktree rm --worktree path:%s" % path
|
|
761
|
+
else:
|
|
762
|
+
gone = git_out(root, "worktree", "remove", path) is not None
|
|
763
|
+
how = "git worktree remove %s" % path
|
|
764
|
+
if gone:
|
|
765
|
+
git_out(root, "branch", "-d", branch)
|
|
766
|
+
why = None if gone else "`%s` failed; run it and report its error" % how
|
|
767
|
+
out.append("worktree %s: %s (%s)" % ("kept" if why else "removed", path,
|
|
768
|
+
why or branch))
|
|
769
|
+
return out
|
|
770
|
+
|
|
771
|
+
|
|
619
772
|
def cmd_status(args):
|
|
620
773
|
as_json = "--json" in args
|
|
621
774
|
rest = [a for a in args if a != "--json"]
|
package/docs/architecture.md
CHANGED
|
@@ -34,7 +34,7 @@ nothing about a host's event names or output envelope.
|
|
|
34
34
|
| `tezgah_paths.py` | where tezgah is armed: [roots](glossary.md#root) and the linked worktrees of a repository under one, kill switches, the config dir, and the writable cache dir - the fallback resolved on use rather than at import, so a gated call does not pay for `tempfile`, `shutil` or `sqlite3` | `hooks/tezgah_paths.py:2-14`, `hooks/tezgah_paths.py::cache_dir`, `hooks/tezgah_paths.py::fallback_cache` |
|
|
35
35
|
| `tezgah_snapshot.py` | pre-write bytes of every file a write is about to change, and the one explicit restore | `hooks/tezgah_snapshot.py:2-18`, `hooks/tezgah_snapshot.py::capture` |
|
|
36
36
|
| `tezgah_untrusted.py` | the untrusted-content label on a result from outside the user and workspace, and the taint notice on the next effect | `hooks/tezgah_untrusted.py:2-22`, `hooks/tezgah_untrusted.py::marks` |
|
|
37
|
-
| `tezgah_agents.py` |
|
|
37
|
+
| `tezgah_agents.py` | subagent definitions generated into each host that has an agent surface: user-level for Claude, Codex and omp (written at install), per repo for Cursor and opencode | `hooks/tezgah_agents.py:2-30`, `hooks/tezgah_agents.py::sync_root`, `hooks/tezgah_agents.py::user_agents` |
|
|
38
38
|
| `tezgah_research.py` | the in-repo research workspace and the check that a protocol predates its results | `hooks/tezgah_research.py:2-85`, `hooks/tezgah_research.py::check` |
|
|
39
39
|
| `tezgah_index.py` | the detached graph auto-index worker (flock-guarded, bounded retry) | `hooks/tezgah_index.py:2-18`, `hooks/tezgah_index.py::main` |
|
|
40
40
|
|
|
@@ -42,10 +42,10 @@
|
|
|
42
42
|
"semicolons": 11
|
|
43
43
|
},
|
|
44
44
|
"docs/gate.md": {
|
|
45
|
-
"long_sentences":
|
|
46
|
-
"passive":
|
|
45
|
+
"long_sentences": 66,
|
|
46
|
+
"passive": 31,
|
|
47
47
|
"replace_words": 1,
|
|
48
|
-
"semicolons":
|
|
48
|
+
"semicolons": 44
|
|
49
49
|
},
|
|
50
50
|
"docs/glossary.md": {
|
|
51
51
|
"long_sentences": 51,
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"semicolons": 6
|
|
73
73
|
},
|
|
74
74
|
"docs/models.md": {
|
|
75
|
-
"long_sentences":
|
|
75
|
+
"long_sentences": 18,
|
|
76
76
|
"passive": 8,
|
|
77
77
|
"replace_words": 0,
|
|
78
78
|
"semicolons": 9
|
package/docs/contract.md
CHANGED
|
@@ -276,7 +276,10 @@ sentence of the rule.
|
|
|
276
276
|
|
|
277
277
|
`PROMPT_HINTS` (`hooks/tezgah_context.py::PROMPT_HINTS`) is one compiled pattern per key
|
|
278
278
|
— `spec`, `consult`, `research`, `product`, `graph`, `fanout` — and `classify_prompt()` returns the keys
|
|
279
|
-
a prompt matches (`hooks/tezgah_context.py::classify_prompt`).
|
|
279
|
+
a prompt matches (`hooks/tezgah_context.py::classify_prompt`). It also adds `product`
|
|
280
|
+
for a data ask: a dashboard, a chart or a metric to choose. The check reads each
|
|
281
|
+
clause on its own (`hooks/tezgah_context.py::_data_ask`). A code marker in that
|
|
282
|
+
clause keeps the ask out: a file name, an identifier, SQL or a build word. `fanout` arms on the
|
|
280
283
|
shape of the request, not a word. Two list entries on their own lines arm it.
|
|
281
284
|
An entry starts with `1.`, `1)`, `1 -`, `1:`, `a)` or a bullet. An inline
|
|
282
285
|
`1. ... 2. ...` arms it too. So do three files named as one list, "First, ...
|
package/docs/feature-audit.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Feature audit: one feature, every layer
|
|
2
2
|
|
|
3
3
|
Audience: the agent and the maintainer answering "why does this screen do that"
|
|
4
|
-
or "make this feature better" on an existing product surface.
|
|
4
|
+
or "make this feature better" on an existing product surface. It also serves
|
|
5
|
+
whoever specifies a feature before the build.
|
|
5
6
|
|
|
6
7
|
A screen review judges what is on the screen. Most of what goes wrong in a real
|
|
7
8
|
feature is not on the screen: it is a disagreement between two layers - the surface
|
|
@@ -20,7 +21,7 @@ sourced to WAI-ARIA APG and WCAG 2.2.
|
|
|
20
21
|
It is reached the same way `product-analysis` is: the `product` task class arms the
|
|
21
22
|
rule (`hooks/tezgah_context.py::PROMPT_HINTS`), whose coherence paragraph
|
|
22
23
|
(`hooks/tezgah_policy.py::CORE`) names the feature unit and the matrices, and
|
|
23
|
-
`skills/product-analysis/SKILL.md:
|
|
24
|
+
`skills/product-analysis/SKILL.md:74` runs the coherence pass before its axes.
|
|
24
25
|
`skills/tezgah-contract/SKILL.md:725-737` carries the long form for a session that
|
|
25
26
|
reads the contract.
|
|
26
27
|
|
|
@@ -38,6 +39,24 @@ step flow, a notification. It is not a form-only method.
|
|
|
38
39
|
| Interaction dependency | the controls whose change alters another's valid set | dependents, required action on change, state owner, announcement | a stale dependent value; a silent reset; a filter showing a state the view no longer has |
|
|
39
40
|
| Surface pattern | the controls, data views, flows and notifications | the decidable rule, with its source | a combobox with no expanded state; a results count announced nowhere; a destructive confirmation that hides what it destroys |
|
|
40
41
|
|
|
42
|
+
## Forward mode: the spec before the build
|
|
43
|
+
|
|
44
|
+
For a feature that does not exist yet, fill the five matrices from the decision
|
|
45
|
+
first. They go into `.tezgah/analysis/<feature>/spec.md` before the build. Mark a
|
|
46
|
+
cell the decision leaves open, and ask it. Never guess it.
|
|
47
|
+
|
|
48
|
+
After the build, run the normal audit and compare it with `spec.md` row by row.
|
|
49
|
+
Every disagreement is a finding. Examples: a capability no layer delivers, a field
|
|
50
|
+
no view renders, a missing state, an unasked layer. The method lives
|
|
51
|
+
in `skills/feature-audit/SKILL.md`, "Forward mode".
|
|
52
|
+
|
|
53
|
+
The decision the spec builds from is the one written under `## decision` in
|
|
54
|
+
`.tezgah/analysis/<topic>/flow.md`. `spec.md` names it, and so does the audit
|
|
55
|
+
after the build. The `product-flow` skill orders the steps around the audit:
|
|
56
|
+
`tezgah-research next --topic <t>` reads the forward spec as done once `spec.md`
|
|
57
|
+
exists (`hooks/tezgah_product.py::next_steps`). See "The next step of a product
|
|
58
|
+
flow" in `docs/research.md`.
|
|
59
|
+
|
|
41
60
|
## The gate that makes it more than advice
|
|
42
61
|
|
|
43
62
|
A finding whose fix names a layer that does not exist yet is not a UI finding. It
|
|
@@ -128,7 +147,7 @@ rubric, then three passes of the feature-audit skill:
|
|
|
128
147
|
## Source of truth
|
|
129
148
|
|
|
130
149
|
- `skills/feature-audit/SKILL.md` - the method: the five matrices, the proposal
|
|
131
|
-
gate, the per-surface rule table and the hand-check list
|
|
150
|
+
gate, the forward-mode spec, the per-surface rule table and the hand-check list
|
|
132
151
|
- `skills/product-analysis/SKILL.md` - the coherence pass and the artifact element
|
|
133
152
|
that carries the matrices
|
|
134
153
|
- `hooks/tezgah_context.py` - `PROMPT_HINTS`, whose `product` alternation carries
|
|
@@ -137,3 +156,5 @@ rubric, then three passes of the feature-audit skill:
|
|
|
137
156
|
`skills/tezgah-contract/SKILL.md` as its long form
|
|
138
157
|
- `tests/test_context.py`, `tests/test_setup.py` - the arming table and the router
|
|
139
158
|
line that pin the wiring
|
|
159
|
+
- `skills/product-flow/SKILL.md` and `hooks/tezgah_product.py::FLOWS` - the step
|
|
160
|
+
order around the forward spec and the audit, printed by `tezgah-research next`
|
package/docs/gate.md
CHANGED
|
@@ -93,7 +93,7 @@ It reads the arguments of `touch`, `rm`, `rmdir`, `unlink`, `mv`, `chmod`, `mkdi
|
|
|
93
93
|
It reads the value of `cp -t`, `curl -o`, `wget -O`, `tar -C` on extraction and `unzip -d` (`OPTION_TARGETS`). It expands `~`, `$HOME` and the XDG
|
|
94
94
|
variables first. It follows a `cd` within the line and reads past `if`/`then`/`do`/`!`/`{`. It opens `eval` and `bash -c`, and any flag cluster holding
|
|
95
95
|
`c` is `-c`. A path counts as written and with its last link resolved. A quoted word is a word, so a grep or a commit message that names a switch passes. `SHELL_WRITE` stays as it was: it feeds the
|
|
96
|
-
task phase rule and the opencode mirror. The task CLI
|
|
96
|
+
task phase rule and the opencode mirror. The task CLI is not a control-plane target: the session runs it itself (see the task rule below). Told: what the call would
|
|
97
97
|
change, and that the user changes it in their own terminal (`CONTROL_DENY` `hooks/tezgah_gate.py::CONTROL_DENY`). The text names no command. Standing. No switch
|
|
98
98
|
but `pretooluse-off` removes the rule, and that switch is a protected file. The rule cannot tell a change the user asked for from one the agent chose. It
|
|
99
99
|
refuses both inside the session, and it never sees the user's own terminal. Plan 050's replay of three weeks of real ledgers found the two evidence-only
|
|
@@ -159,22 +159,21 @@ Scratch is skipped: a temp file or a device (`hooks/tezgah_integrity.py::scratch
|
|
|
159
159
|
Told: re-read the file and re-apply the change to what is on disk now (`RACE_DENY`
|
|
160
160
|
`hooks/tezgah_gate.py::RACE_DENY`). Standing (`RACE_REFUSE = True` `hooks/tezgah_gate.py::RACE_REFUSE`) — a notice would not close a silent overwrite — and it lifts only by time.
|
|
161
161
|
|
|
162
|
-
### Task — the
|
|
162
|
+
### Task — the active plan's phase and path allowlist
|
|
163
163
|
|
|
164
164
|
Trigger: a write tool, or a shell command that writes a file, while an active task is set — a plan file under `.tezgah/plans/open` whose frontmatter carries a valid
|
|
165
165
|
`phase` (`tezgah_task.active`). The phase refuses a write in a reading phase (`hooks/tezgah_gate.py::TASK_PHASE_DENY`) and a shell command that writes one (`hooks/tezgah_gate.py::TASK_SHELL_DENY`,
|
|
166
|
-
against `hooks/tezgah_gate.py::SHELL_WRITE`); the allowlist refuses a target outside it (`hooks/tezgah_gate.py::TASK_SCOPE_DENY`) — a write tool's path, and a shell command's redirect or `tee` target (`hooks/tezgah_gate.py::write_paths`); and
|
|
167
|
-
(`hooks/tezgah_gate.py::TASK_RECORD_DENY`)
|
|
168
|
-
|
|
169
|
-
|
|
166
|
+
against `hooks/tezgah_gate.py::SHELL_WRITE`); the allowlist refuses a target outside it (`hooks/tezgah_gate.py::TASK_SCOPE_DENY`) — a write tool's path, and a shell command's redirect or `tee` target (`hooks/tezgah_gate.py::write_paths`); and a write whose target IS the record, by a write tool or a shell redirect/`tee`, is refused
|
|
167
|
+
(`hooks/tezgah_gate.py::TASK_RECORD_DENY`), because only `bin/tezgah-task` writes it and that CLI makes the checks a retyped frontmatter would skip. Told:
|
|
168
|
+
the move that lifts it, made by the session itself — `tezgah-task phase implementation` or `tezgah-task allow <glob> ... --why "<reason>"` — since the orchestrator owns the plan lifecycle and the CLI refuses
|
|
169
|
+
the move until the plan's Acceptance, spike and checkpoint are in order, and records the reason for a widened scope (`allowed_why:`). Standing: only the record changing lifts it.
|
|
170
170
|
Scratch meets no allowlist: a temp file or a device, such as `pytest > /tmp/check.log` (`hooks/tezgah_gate.py::task_reason`). Build output inside the repo stays held. Off: `task-off`
|
|
171
|
-
(`hooks/tezgah_gate.py::decision`). It fails open wherever the question cannot be answered: no active task, no readable record
|
|
172
|
-
never by the agent, so this rule refuses on a decision the user made rather than one the gate inferred.
|
|
171
|
+
(`hooks/tezgah_gate.py::decision`). It fails open wherever the question cannot be answered: no active task, no readable record.
|
|
173
172
|
|
|
174
|
-
The
|
|
173
|
+
The fourth refusal under the same rule is the checkpoint. Trigger: a write, or a shell command that writes a file (`hooks/tezgah_gate.py::SHELL_WRITE`), while the task is in
|
|
175
174
|
`phase: implementation`, its record reads `checkpoint: pending <sha>`, that sha is still the checkout's HEAD and the tree is dirty (`checkpoint_reason()`
|
|
176
175
|
`hooks/tezgah_gate.py::checkpoint_reason`, `hooks/tezgah_gate.py::CHECKPOINT_DENY`). Told: the task it read, that the record holds a `pending` checkpoint, and the one command
|
|
177
|
-
that makes the boundary real -
|
|
176
|
+
that makes the boundary real - it names that command because running it *satisfies* the rule rather than taking it off. Standing: the recorded sha is the switch, not the tree - the commit
|
|
178
177
|
the refusal names moves HEAD off that sha, so the refusal clears itself, where keying on the word `pending` alone left the phase waiting on a record only
|
|
179
178
|
`bin/tezgah-task` rewrites. Off, with the rest of the rule: `task-off`. It fails open wherever the question cannot be answered: no record, no `checkpoint:` key, a
|
|
180
179
|
checkpoint that is a plain sha (the boundary is already real), a `pending` with no sha, a sha this checkout's HEAD does not name, and a repo whose HEAD cannot be read.
|
|
@@ -236,7 +235,7 @@ tail still shows that failure, under `verify-off` (`hooks/tezgah_gate.py::decisi
|
|
|
236
235
|
### Loop — the identical attempt that already failed twice
|
|
237
236
|
|
|
238
237
|
Trigger: the same call (same `call_id`) failed `LOOP_ATTEMPTS = 2` times already in the current user turn (`hooks/tezgah_gate.py::LOOP_ATTEMPTS`, `hooks/tezgah_gate.py::loop_reason`). Told: attempt N
|
|
239
|
-
of an identical call, the failure class read from the ledger (`transient`, `user`, `permanent`, `unknown`; the cap is the same for every class, and a `user` failure - a 401/403, a missing credential, a login - says to ask the user), "change the approach or stop" (`hooks/tezgah_gate.py::loop_reason`). Standing while the ledger tail still shows those
|
|
238
|
+
of an identical call, the failure class read from the ledger (`transient`, `user`, `permanent`, `unknown`; the cap is the same for every class, and a `user` failure - a 401/403, a missing credential, a login - says to take a route that needs no credential first and ask the user once only when none is left), "change the approach or stop" (`hooks/tezgah_gate.py::loop_reason`). Standing while the ledger tail still shows those
|
|
240
239
|
failures. Under `verify-off` (`hooks/tezgah_gate.py::decision`).
|
|
241
240
|
|
|
242
241
|
### Retry — the session-wide ceiling
|
package/docs/hosts.md
CHANGED
|
@@ -71,6 +71,8 @@ shared config, contract hash and the CLI symlinks every host shell can call
|
|
|
71
71
|
`~/.claude/settings.json` - `statusLine` (`bin/tezgah-setup::wire_claude_statusline`) and the attribution
|
|
72
72
|
off-switch (`bin/tezgah-setup::wire_claude_attribution`) - and the always-on block in
|
|
73
73
|
`~/.claude/CLAUDE.md` (`CLAUDE_RULES`), Claude's own user-level memory file.
|
|
74
|
+
The subagent roles go to `~/.claude/agents/tezgah-*.md`
|
|
75
|
+
(`bin/tezgah-setup::write_user_agents`).
|
|
74
76
|
Its hook manifest is the plugin's own
|
|
75
77
|
`hooks/hooks.json`; no `hooks.json` is written into `~/.claude`. Claude runs a
|
|
76
78
|
**copy** of the checkout under `~/.claude/plugins/cache`, refreshed by
|
|
@@ -84,6 +86,8 @@ shared config, contract hash and the CLI symlinks every host shell can call
|
|
|
84
86
|
(one group per event, PreToolUse carrying `CODEX_PRETOOL_MATCHER` at `bin/tezgah-setup::CODEX_PRETOOL_MATCHER`),
|
|
85
87
|
`skills/*` symlinks, `~/.codex/bin/consult`, and `mcp_servers.*` tables in
|
|
86
88
|
`config.toml` (`ensure_toml_mcp`, `bin/tezgah-setup::ensure_toml_mcp`).
|
|
89
|
+
The subagent roles go to `$CODEX_HOME/agents/tezgah-*.toml`
|
|
90
|
+
(`bin/tezgah-setup::write_user_agents`).
|
|
87
91
|
- **cursor** - `install_cursor` (`bin/tezgah-setup::install_cursor`): `~/.cursor/hooks.json`
|
|
88
92
|
(tezgah entries replaced, a user's Orca entries merged), `skills/*` symlinks,
|
|
89
93
|
`mcp.json` servers, and `cli-config.json` `statusLine`.
|
|
@@ -159,6 +163,11 @@ and drops the later ones.
|
|
|
159
163
|
| skills | codex | `.agents/skills` from the cwd up to the repo root, `~/.agents/skills`, `$CODEX_HOME/skills`; one name in two dirs is listed twice, not merged | links in `$CODEX_HOME/skills` |
|
|
160
164
|
| skills | cursor | `.agents/skills`, `.cursor/skills`, `~/.agents/skills`, `~/.cursor/skills`, then Claude's and Codex's project and user dirs; no documented winner | links in `~/.cursor/skills` |
|
|
161
165
|
| skills | opencode | its own skill tool is denied; the router lists `~/.config/opencode/skills`, `~/.claude/skills`, `~/.agents/skills`, first wins (`bin/tezgah-setup::skill_groups`) | links and the router |
|
|
166
|
+
| agents | claude | managed, the `claude --agents` flag, `.claude/agents`, `~/.claude/agents`, plugin agents namespaced `plugin:name`; a dir absent at start is not watched | `~/.claude/agents`, written at install |
|
|
167
|
+
| agents | codex | `$CODEX_HOME/agents`, `/etc/codex/agents`, `.codex/agents` (trusted repos only), read at config load | `$CODEX_HOME/agents`, written at install |
|
|
168
|
+
| agents | cursor | `cursor-agent`: `.cursor/agents`, then `.claude/agents`, `.grok/agents`, plugin agents; first name wins; no user dir; read at process start. The editor's docs add `~/.cursor/agents` and `~/.claude/agents` | `.cursor/agents` per repo at session start, in the CLI's line format (`hooks/tezgah_agents.py::render_md_cursor`) |
|
|
169
|
+
| agents | opencode | `~/.config/opencode/agents`, `.opencode/agents`, merged per field | `config` hook injection, `.opencode/agents` as fallback |
|
|
170
|
+
| agents | omp | `.omp/agents`, `~/.omp/agent/agents`, extensions, bundled | `~/.omp/agent/agents`, written at install |
|
|
162
171
|
| hooks | claude | user `settings.json` rows (Orca's), project rows, the plugin's `hooks/hooks.json` (tezgah's); every matching row runs, none is ordered before another | plugin rows |
|
|
163
172
|
| hooks | codex, cursor | `hooks.json` array order, then the repo's `.codex/` or `.cursor/hooks.json`; Codex runs a row only once its hash is trusted | own rows after Orca's |
|
|
164
173
|
| hooks | opencode | every `*.js`/`*.ts` in `plugins/` and `plugin/`; `tezgah.js` is linked into both, and its `globalThis.__tezgahPluginLoaded` guard makes the second load return `{}` | the plugin |
|