@r1z4x/tezgah 1.5.1 → 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 (42) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/MANIFEST +6 -0
  3. package/bin/consult +13 -10
  4. package/bin/tezgah-docs +1 -1
  5. package/bin/tezgah-research +53 -3
  6. package/bin/tezgah-setup +1 -1
  7. package/bin/tezgah-task +192 -39
  8. package/docs/citations-baseline.json +1 -1
  9. package/docs/clarity-baseline.json +3 -3
  10. package/docs/contract.md +4 -1
  11. package/docs/feature-audit.md +24 -3
  12. package/docs/gate.md +10 -11
  13. package/docs/index.json +21 -7
  14. package/docs/research.md +46 -0
  15. package/docs/skills.md +23 -5
  16. package/hooks/tezgah_agents.py +3 -2
  17. package/hooks/tezgah_components.py +2 -1
  18. package/hooks/tezgah_context.py +65 -14
  19. package/hooks/tezgah_gate.py +62 -76
  20. package/hooks/tezgah_policy.py +38 -14
  21. package/hooks/tezgah_product.py +107 -0
  22. package/hooks/tezgah_research.py +3 -3
  23. package/hooks/tezgah_skill_pick.py +58 -17
  24. package/hooks/tezgah_task.py +2 -2
  25. package/hosts/opencode/plugins/tezgah.js +17 -37
  26. package/package.json +1 -1
  27. package/skills/analyze-app/SKILL.md +57 -0
  28. package/skills/design-contract/EVALS.md +24 -1
  29. package/skills/design-contract/SKILL.md +17 -1
  30. package/skills/design-contract/data-views.md +86 -0
  31. package/skills/feature-audit/SKILL.md +32 -3
  32. package/skills/plan-add/EVALS.md +8 -8
  33. package/skills/plan-add/SKILL.md +20 -21
  34. package/skills/plan-sync/EVALS.md +2 -2
  35. package/skills/plan-sync/SKILL.md +11 -7
  36. package/skills/pm-frameworks/SKILL.md +6 -0
  37. package/skills/product-analysis/EVALS.md +23 -1
  38. package/skills/product-analysis/SKILL.md +50 -11
  39. package/skills/product-flow/EVALS.md +81 -0
  40. package/skills/product-flow/SKILL.md +91 -0
  41. package/skills/research/SKILL.md +10 -1
  42. package/skills/tezgah-contract/SKILL.md +63 -39
package/CHANGELOG.md CHANGED
@@ -6,6 +6,51 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.6.0] - 2026-10-10
10
+
11
+ ### Added
12
+
13
+ - **A data, metric or dashboard ask reaches the product rule.** "Bu veriye uygun bir metric dashboard tasarla" armed no rule before. A dashboard, chart or metric-choice ask now arms `product`, judged per clause. A code marker in the same clause, such as a metrics endpoint, keeps it out.
14
+ - **product-analysis chooses metrics.** It names a North Star and its input metrics. Each metric gets one record: goal, signal, definition, window, baseline and more.
15
+ - **Dashboard rules in design-contract.** The new `data-views.md` picks the chart for the question. It also sets the context, the window and the widget states.
16
+ - **feature-audit forward mode.** A feature not built yet gets its five matrices in `spec.md`. The audit after the build compares against it.
17
+ - **analyze-app writes the design measurement.** Nothing produced `measurement.json` before. The skill now gives its shape and the snippet that reads it.
18
+ - **The local skill search reaches design-library's entries.** The search now reads the entries a skill's `INDEX.md` lists, one row each.
19
+ - **The product-flow skill and `tezgah-research next`.** A new product capability now runs in a fixed order, from research to re-measure. Each step leaves a file under `.tezgah/analysis/<topic>/`: a `## <step>` note in `flow.md`, or `spec.md` for the forward spec. `tezgah-research next --topic <t>` prints each step's status from those files, and a re-measure is due once the decision's `observe after:` date has passed. It then names the first missing or due step's skill and command. The skill lists the steps, and `FLOWS` in `hooks/tezgah_product.py` holds their done rules.
20
+
21
+ ### Removed
22
+
23
+ - **The product ledger is cut before release (ADR 025).** Plan 075's measured run did not show the ledger beating the playbook alone. Gone: writing and reading `.tezgah/product/ledger.jsonl`, `tezgah-research record`, `due` and `commit --product`, the ledger part of `check`, `product:` citations and the session-start note on due decisions. An existing `ledger.jsonl` is left in place, and nothing reads it.
24
+
25
+ ### Changed
26
+
27
+ - **The session runs the plan lifecycle itself.** The gate refused a session's
28
+ own `tezgah-task start|phase|allow|stop`. It told the session to ask the user,
29
+ so every plan ended with a command handed back. That refusal is gone. A phase
30
+ or scope refusal now names the move (`tezgah-task phase implementation`,
31
+ `tezgah-task allow`). The CLI still checks Acceptance, the spike, the
32
+ checkpoint and the review on each move. The gate still refuses a write to the
33
+ plan file itself. `tezgah-task allow` now needs `--why`, and `status` prints
34
+ the reason next to the widened scope.
35
+ - **`close` cleans up.** `tezgah-task close` on the active task clears its
36
+ phase instead of refusing. It then removes each linked worktree whose branch
37
+ has landed and which holds nothing else, through Orca when Orca tracks it. A
38
+ squash merge counts as landed. A branch whose commits cancel out does not. It
39
+ prints every worktree it keeps, with the reason. Uncommitted files, unknown
40
+ ignored files and `.tezgah` data keep a worktree. So do an unmerged branch, a
41
+ branch nobody committed on, an agent at work and an Orca that does not answer.
42
+ - **No process questions.** The contract says the orchestrator picks a process
43
+ parameter the user did not set, such as a spend ceiling, and states it.
44
+ `consult` on exit 4/5 records the first member of another model family
45
+ itself instead of asking the user to choose. After two review rounds the
46
+ session fixes what is left and closes with `close NNN done --resolved`. A
47
+ missing credential sends it to a route that needs none first. A silent gate
48
+ has it run `tezgah-setup --report` itself.
49
+
50
+ ### Fixed
51
+
52
+ - **product-analysis no longer says `check` demands three compared solutions.** An analysis answers once. A choice between solutions goes under `decisions/`.
53
+
9
54
  ## [1.5.1] - 2026-10-09
10
55
 
11
56
  ### Fixed
@@ -981,7 +1026,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
981
1026
  the copy could not find `hooks/` and died on import. It now links into the
982
1027
  installed tree, and also installs the command as `tezgah`.
983
1028
 
984
- [Unreleased]: https://github.com/r1z4x/tezgah/compare/v1.5.1...HEAD
1029
+ [Unreleased]: https://github.com/r1z4x/tezgah/compare/v1.6.0...HEAD
1030
+ [1.6.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.6.0
985
1031
  [1.5.1]: https://github.com/r1z4x/tezgah/releases/tag/v1.5.1
986
1032
  [1.5.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.5.0
987
1033
  [1.4.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.4.0
package/MANIFEST CHANGED
@@ -133,6 +133,7 @@ hooks/tezgah_models.py
133
133
  hooks/tezgah_orca.py
134
134
  hooks/tezgah_paths.py
135
135
  hooks/tezgah_policy.py
136
+ hooks/tezgah_product.py
136
137
  hooks/tezgah_rank.py
137
138
  hooks/tezgah_replay.py
138
139
  hooks/tezgah_research.py
@@ -545,6 +546,7 @@ skills/analyze-app/EVALS.md
545
546
  skills/analyze-app/SKILL.md
546
547
  skills/design-contract/EVALS.md
547
548
  skills/design-contract/SKILL.md
549
+ skills/design-contract/data-views.md
548
550
  skills/design-library/EVALS.md
549
551
  skills/design-library/INDEX.md
550
552
  skills/design-library/SKILL.md
@@ -612,6 +614,8 @@ skills/ponytail/EVALS.md
612
614
  skills/ponytail/SKILL.md
613
615
  skills/product-analysis/EVALS.md
614
616
  skills/product-analysis/SKILL.md
617
+ skills/product-flow/EVALS.md
618
+ skills/product-flow/SKILL.md
615
619
  skills/research/EVALS.md
616
620
  skills/research/SKILL.md
617
621
  skills/rl-env/SKILL.md
@@ -733,6 +737,8 @@ tests/test_packaging_scripts.py
733
737
  tests/test_paths.py
734
738
  tests/test_paths_worktree.py
735
739
  tests/test_platform.py
740
+ tests/test_product_flow_e2e.py
741
+ tests/test_product_next.py
736
742
  tests/test_providers.py
737
743
  tests/test_rank.py
738
744
  tests/test_replay.py
package/bin/consult CHANGED
@@ -56,7 +56,8 @@ suffix); 2 no key for the chosen provider, or no member available at all; 3 the
56
56
  --provider/--models run got no answer; 4 no recorded choice - one "offer:" line
57
57
  per available member, nothing was asked; 5 every recorded member (and the
58
58
  fallback) failed - one "reoffer:" line per member still available that did not
59
- fail. On 4 or 5, ask the user which to use and record it with --use.
59
+ fail. On 4 or 5, the agent records the first listed member of another model
60
+ family with --use itself and asks again; the user is not asked to choose.
60
61
  """
61
62
  import concurrent.futures
62
63
  import json
@@ -504,11 +505,11 @@ def retry_hint(classes):
504
505
  return "the referee answered outside its five headings; re-run, or judge the panel yourself"
505
506
  if c == "credit":
506
507
  return ("the provider account has no credit (HTTP 402) - the key is fine; "
507
- "top it up, or ask the user for another member and record it with --use")
508
+ "top it up, or record another member with --use")
508
509
  if c == "key":
509
- return "set the provider's key, or ask the user for another member and record it with --use"
510
+ return "set the provider's key, or record another member with --use"
510
511
  if c == "missing":
511
- return "the agent CLI is not installed; ask the user for another member and record it with --use"
512
+ return "the agent CLI is not installed; record another member with --use"
512
513
  if c.startswith("cli-exit"):
513
514
  return ("the agent CLI failed (not logged in, or a model name it does not "
514
515
  "know); run it once by hand, or record another member with --use")
@@ -645,8 +646,9 @@ def main():
645
646
  print("consult: no recorded choice, so nothing was asked.")
646
647
  for opt in found:
647
648
  print(offer_line("offer", opt))
648
- print("next: ask the user which of these to use (two or more make a "
649
- "panel), then run: consult --use <member>[,<member>] and ask again")
649
+ print("next: record the first of these from a model family other than "
650
+ "yours (two make a panel) with: consult --use <member>[,<member>], "
651
+ "then ask again - do not ask the user to choose")
650
652
  sys.stdout.flush()
651
653
  os._exit(4)
652
654
 
@@ -775,14 +777,15 @@ def main():
775
777
  print("retry: %s" % hint)
776
778
  code = 0 if answers else 3
777
779
  if not answers and not explicit:
778
- # The recorded choice is the user's, so consult never swaps it on its
779
- # own: it names what is left and the agent asks again.
780
+ # consult never swaps the recorded choice on its own: it names what is
781
+ # left, and the agent records the next member and asks again.
780
782
  lost = {c[1] for c, _cls in failures} | skip_opts
781
783
  left = [o for o in options() if o not in lost]
782
784
  for opt in left:
783
785
  print(offer_line("reoffer", opt))
784
- print("next: ask the user which of these to use instead, then run: "
785
- "consult --use <member>[,<member>] and ask again" if left else
786
+ print("next: record the first of these from another model family with: "
787
+ "consult --use <member>[,<member>], then ask again - do not ask the "
788
+ "user to choose" if left else
786
789
  "next: nothing else can answer here; tell the user the second "
787
790
  "opinion was skipped (install an agent CLI or add a provider key)")
788
791
  code = 5
package/bin/tezgah-docs CHANGED
@@ -106,7 +106,7 @@ RUN = re.compile(r"((?:`[A-Za-z_][A-Za-z0-9_]*(?:\(\))?`\s*[/,]?\s*){1,5})$")
106
106
  CITE = re.compile(r"`([\w.-]+(?:/[\w.-]+)+|[\w-]+\.\w+):(\d+)(?:-(\d+))?`"
107
107
  r"|`(:(\d+)(?:-(\d+))?)`")
108
108
  # The symbol written after the citation instead of before it, the way a table
109
- # cell reads: `hooks/tezgah_policy.py:680-948` (`CORE`).
109
+ # cell reads: `hooks/tezgah_policy.py:697-972` (`CORE`).
110
110
  AFTER = re.compile(r"\s*\(`([A-Za-z_][A-Za-z0-9_]*)(?:\(\))?`\)")
111
111
  # Any `path:line`, backticked or not: what CITE does not match of these (a
112
112
  # bare token, a comma list `x.py:12,40`) is counted as unjudged, so a citation
@@ -16,11 +16,17 @@
16
16
  tezgah-research source <slug> <hypothesis> --run <orxRunId> [--command "<cmd>"]
17
17
  [--scope real|fixture|derived] [--fixture "<what was generated>"]
18
18
  tezgah-research compare <slug> <decision> record one variants x criteria cell from stdin
19
+ tezgah-research verdict <slug> <id> met|not-met|unanswerable --evidence "<ref>"
20
+ record one success criterion's verdict
21
+ tezgah-research conclude <slug> conclude a line whose criteria all carry a verdict
22
+ tezgah-research migrate-layout move flat research/<slug> into open/ or done/
19
23
  tezgah-research close <slug> --limit "<reason>" conclude a line as a deliberate limit
20
24
  tezgah-research seal <slug> [--history-lost] --ack "<owner decision>"
21
25
  seal a line concluded before seals existed
22
26
  tezgah-research import <checkout> [<slug>] [--allow-open "<why>"]
23
27
  bring lines from another checkout, with history
28
+ tezgah-research next --topic <t> [--flow metric-dashboard|feature] [--json]
29
+ the product-flow steps of a topic and the next one
24
30
 
25
31
  A research line lives at <repo>/.tezgah/research/<slug>/ and holds the state an
26
32
  auditable investigation needs: the question, the decision log, the findings, the
@@ -103,6 +109,7 @@ import time
103
109
  HERE = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
104
110
  sys.path.insert(0, os.path.join(HERE, "hooks"))
105
111
  import tezgah_paths as tp # noqa: E402
112
+ import tezgah_product as tp_product # noqa: E402
106
113
  import tezgah_research as tr # noqa: E402
107
114
 
108
115
 
@@ -245,12 +252,17 @@ def cmd_commit(args):
245
252
  known = tr.slugs(repo)
246
253
  if slug not in known:
247
254
  return misuse("no such research line (have: %s)" % (", ".join(known) or "none"))
255
+ return commit_path(repo, tr.line_dir(repo, slug), message)
256
+
257
+
258
+ def commit_path(repo, path, message):
259
+ """Stage and commit `path` alone in the private repository of `repo`."""
248
260
  ws = tp.ensure_workspace(repo)
249
261
  if ws is None:
250
262
  print("commit refused: %s is not a git work tree (or .tezgah could not be "
251
263
  "set up), so there is no private repository to commit into" % repo)
252
264
  return 1
253
- rel = os.path.relpath(tr.line_dir(repo, slug), ws)
265
+ rel = os.path.relpath(path, ws)
254
266
  try:
255
267
  done = tp.ws_git(repo, "add", "-A", "--", rel)
256
268
  if not done.returncode:
@@ -298,6 +310,7 @@ def cmd_check(args):
298
310
  print("warn %s: %s" % (slug, warn))
299
311
  else:
300
312
  print("no research line under %s" % tr.root(repo))
313
+ lines = len(report)
301
314
  if ask_orx:
302
315
  # The registry is the repository's, not a line's, so this runs whatever the
303
316
  # lines look like - which is also why it is only asked for when --orx says so.
@@ -313,7 +326,7 @@ def cmd_check(args):
313
326
  print(json.dumps(report, indent=2))
314
327
  return 1 if problems else 0
315
328
  if not problems:
316
- print("research: %d line(s) ok" % len(report))
329
+ print("research: %d line(s) ok" % lines)
317
330
  if not rest and not all_lines:
318
331
  done = [slug for slug in known if tr.sealed(repo, slug)]
319
332
  if done:
@@ -576,6 +589,40 @@ def cmd_compare(args):
576
589
  return 0
577
590
 
578
591
 
592
+ def cmd_next(args):
593
+ """The product-flow steps of one topic with their status, then the first
594
+ missing or due step's skill and command; a report, so exit 0."""
595
+ usage = "next --topic <t> [--flow %s] [--json]" % "|".join(tp_product.FLOWS)
596
+ opts, as_json, rest = {}, False, list(args)
597
+ while rest:
598
+ arg = rest.pop(0)
599
+ if arg == "--json":
600
+ as_json = True
601
+ elif arg in ("--topic", "--flow") and rest and arg not in opts:
602
+ opts[arg] = rest.pop(0)
603
+ else:
604
+ return misuse(usage)
605
+ flow = opts.get("--flow", "metric-dashboard")
606
+ if "--topic" not in opts:
607
+ return misuse(usage)
608
+ if flow not in tp_product.FLOWS:
609
+ return misuse("unknown flow %r (%s)" % (flow, ", ".join(tp_product.FLOWS)))
610
+ topic = opts["--topic"]
611
+ steps = tp_product.next_steps(repo_root(os.getcwd()), topic, flow)
612
+ if as_json:
613
+ # the step list next_steps returns; the next step is its first
614
+ # missing or due entry
615
+ print(json.dumps(steps, indent=2))
616
+ return 0
617
+ first = next((s for s in steps if s["status"] in ("missing", "due")), None)
618
+ for n, step in enumerate(steps, 1):
619
+ print("%d. %s [%s] %s - %s" % (n, step["id"], step["status"], step["skill"],
620
+ step["detail"]))
621
+ print("next: %s - %s" % (first["skill"], first["command"]) if first
622
+ else "next: nothing left")
623
+ return 0
624
+
625
+
579
626
  def cmd_verdict(args):
580
627
  """Record one success criterion's verdict, with the evidence it rests on."""
581
628
  if len(args) < 3:
@@ -769,6 +816,8 @@ def main(argv):
769
816
  return cmd_source(rest)
770
817
  if cmd == "compare":
771
818
  return cmd_compare(rest)
819
+ if cmd == "next":
820
+ return cmd_next(rest)
772
821
  if cmd == "verdict":
773
822
  return cmd_verdict(rest)
774
823
  if cmd == "conclude":
@@ -782,7 +831,8 @@ def main(argv):
782
831
  if cmd == "import":
783
832
  return cmd_import(rest)
784
833
  return misuse("unknown command %r (init, commit, check, status, --all, claim, predict, "
785
- "components, migrate, source, compare, close, seal, import)" % cmd)
834
+ "components, migrate, source, compare, verdict, conclude, migrate-layout, "
835
+ "close, seal, import, next)" % cmd)
786
836
 
787
837
 
788
838
  if __name__ == "__main__":
package/bin/tezgah-setup CHANGED
@@ -187,7 +187,7 @@ OMP_LESSONS = ("Read `.tezgah/lessons.md` in the repository if it exists and tre
187
187
  LOCAL_BIN = os.path.join(HOME, ".local", "bin")
188
188
  SKILLS = ["harness", "tezgah-contract", "ponytail", "i-have-adhd", "no-ai-slop",
189
189
  "analyze-app", "plan-add", "plan-status", "plan-sync", "research",
190
- "product-analysis", "feature-audit", "design-contract",
190
+ "product-analysis", "product-flow", "feature-audit", "design-contract",
191
191
  "design-library", "pm-frameworks", "ai-research", "rl-env"]
192
192
 
193
193
 
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)
@@ -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"]