@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/MANIFEST +6 -1
  3. package/README.md +6 -4
  4. package/bin/consult +13 -10
  5. package/bin/tezgah-docs +1 -1
  6. package/bin/tezgah-research +53 -3
  7. package/bin/tezgah-route +8 -5
  8. package/bin/tezgah-setup +45 -25
  9. package/bin/tezgah-task +194 -41
  10. package/docs/architecture.md +1 -1
  11. package/docs/citations-baseline.json +1 -1
  12. package/docs/clarity-baseline.json +4 -4
  13. package/docs/contract.md +4 -1
  14. package/docs/feature-audit.md +24 -3
  15. package/docs/gate.md +10 -11
  16. package/docs/hosts.md +9 -0
  17. package/docs/index.json +21 -7
  18. package/docs/judge.md +3 -1
  19. package/docs/layers.md +1 -1
  20. package/docs/models.md +17 -7
  21. package/docs/operations.md +0 -1
  22. package/docs/research.md +46 -0
  23. package/docs/skills.md +23 -5
  24. package/docs/testing.md +1 -1
  25. package/hooks/tezgah_agents.py +130 -96
  26. package/hooks/tezgah_components.py +2 -1
  27. package/hooks/tezgah_context.py +67 -15
  28. package/hooks/tezgah_gate.py +62 -76
  29. package/hooks/tezgah_judge.py +15 -8
  30. package/hooks/tezgah_models.py +50 -3
  31. package/hooks/tezgah_paths.py +26 -11
  32. package/hooks/tezgah_policy.py +47 -19
  33. package/hooks/tezgah_product.py +107 -0
  34. package/hooks/tezgah_research.py +3 -3
  35. package/hooks/tezgah_skill_pick.py +58 -17
  36. package/hooks/tezgah_task.py +16 -8
  37. package/hosts/opencode/plugins/tezgah.js +17 -37
  38. package/package.json +1 -2
  39. package/skills/analyze-app/SKILL.md +57 -0
  40. package/skills/design-contract/EVALS.md +24 -1
  41. package/skills/design-contract/SKILL.md +17 -1
  42. package/skills/design-contract/data-views.md +86 -0
  43. package/skills/feature-audit/SKILL.md +32 -3
  44. package/skills/plan-add/EVALS.md +8 -8
  45. package/skills/plan-add/SKILL.md +20 -21
  46. package/skills/plan-sync/EVALS.md +2 -2
  47. package/skills/plan-sync/SKILL.md +11 -7
  48. package/skills/pm-frameworks/SKILL.md +6 -0
  49. package/skills/product-analysis/EVALS.md +23 -1
  50. package/skills/product-analysis/SKILL.md +50 -11
  51. package/skills/product-flow/EVALS.md +81 -0
  52. package/skills/product-flow/SKILL.md +91 -0
  53. package/skills/research/SKILL.md +10 -1
  54. package/skills/tezgah-contract/SKILL.md +67 -41
  55. 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 replace the allowlist
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 move a plan to .tezgah/plans/done
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. That makes the gate's refusal a rule the user
15
- set in advance, so this CLI is the only writer and the agent never runs its
16
- first four commands - `stop` and a hand edit are equally valid ways to take the
17
- requirement away. `phase implementation` (or `verification`) is what lets a write
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
- `review` and `close` are the session's: the verification phase asks for one
26
- review of the plan's whole diff, `review` records its verdict as
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`. `close` refuses the active task - that one is the user's to stop first.
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 the record is the user's and the refusal is
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
- source = tt.frontmatter(text).get("allowed_from") or ""
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, and the record
289
- is the user's to write - a gate rule would have to re-read the same body on
290
- every write to ask a question whose answer changes only here."""
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: `pnpm test`
333
- # carries no option or path and would otherwise vanish from the list
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. A critical or major finding still confirmed goes to "
360
- "the user (block); a minor or suggestion one is recorded fix-later "
361
- "under the plan's ## State" % REVIEW_ROUNDS)
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
- elif any_path:
449
- fields.update(allowed_paths=[], allowed_from=ANY_PATH)
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: what is left belongs
550
- to the user, never to a third round."""
551
- return ("%s has spent its %d-round review budget: never a third round. A "
552
- "critical or major finding still confirmed goes to the user, who "
553
- "decides (block); a minor or suggestion one is recorded fix-later "
554
- "under the plan's ## State; `tezgah-task close %s discarded` needs no "
555
- "review" % (path, REVIEW_ROUNDS, ident))
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
- return problems(["%s is the active task: the user stops it first "
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
- if prior.isdigit() and int(prior) >= REVIEW_ROUNDS:
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
- return problems(["%s records no approving review: a fresh reviewer reads "
604
- "the diff, then `tezgah-task review %s <reviewer> approve`"
605
- % (path, ident)])
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, status=args[1], updated=today())
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"]
@@ -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` | per-repo subagent definitions generated into each host that has an agent surface | `hooks/tezgah_agents.py:2-22`, `hooks/tezgah_agents.py::sync_root` |
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
 
@@ -21,7 +21,7 @@
21
21
  "unjudged": 2
22
22
  },
23
23
  "docs/gate.md": {
24
- "unjudged": 40
24
+ "unjudged": 39
25
25
  },
26
26
  "docs/glossary.md": {
27
27
  "unjudged": 12
@@ -42,10 +42,10 @@
42
42
  "semicolons": 11
43
43
  },
44
44
  "docs/gate.md": {
45
- "long_sentences": 67,
46
- "passive": 32,
45
+ "long_sentences": 66,
46
+ "passive": 31,
47
47
  "replace_words": 1,
48
- "semicolons": 45
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": 19,
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`). `fanout` arms on the
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, ...
@@ -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:51` runs the coherence pass before its axes.
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 stays the task rule's (below), so `task-off` still removes all of that rule. Told: what the call would
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 user's own phase and path allowlist
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 the record itself is refused twice over — a write whose target IS it, by a write tool or a shell redirect/`tee`
167
- (`hooks/tezgah_gate.py::TASK_RECORD_DENY`) and a shell command that would change it through the CLI, named by its basename with or without `.py` (`hooks/tezgah_gate.py::TASK_UNLOCK_DENY`, matched on the masked text by `hooks/tezgah_gate.py::TASK_CHANGE`). Told:
168
- none of them names the command that lifts it, on purpose — with the unlock printed, an armed arm removed or disabled the gate in 25 of 25 runs and obeyed it in none
169
- (`hooks/tezgah_gate.py:882-886`) — so each names the phase or the allowlist as the user's to change. Standing: only the record changing lifts it.
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; the record is written by `bin/tezgah-task` and
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 fifth 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
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 - unlike the refusals above 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
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 |