@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.
- package/CHANGELOG.md +47 -1
- package/MANIFEST +6 -0
- package/bin/consult +13 -10
- package/bin/tezgah-docs +1 -1
- package/bin/tezgah-research +53 -3
- package/bin/tezgah-setup +1 -1
- package/bin/tezgah-task +192 -39
- package/docs/citations-baseline.json +1 -1
- package/docs/clarity-baseline.json +3 -3
- package/docs/contract.md +4 -1
- package/docs/feature-audit.md +24 -3
- package/docs/gate.md +10 -11
- package/docs/index.json +21 -7
- package/docs/research.md +46 -0
- package/docs/skills.md +23 -5
- package/hooks/tezgah_agents.py +3 -2
- package/hooks/tezgah_components.py +2 -1
- package/hooks/tezgah_context.py +65 -14
- package/hooks/tezgah_gate.py +62 -76
- package/hooks/tezgah_policy.py +38 -14
- 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 +2 -2
- package/hosts/opencode/plugins/tezgah.js +17 -37
- package/package.json +1 -1
- 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 +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.
|
|
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,
|
|
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
|
|
508
|
+
"top it up, or record another member with --use")
|
|
508
509
|
if c == "key":
|
|
509
|
-
return "set the provider's key, or
|
|
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;
|
|
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:
|
|
649
|
-
"panel)
|
|
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
|
-
#
|
|
779
|
-
#
|
|
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:
|
|
785
|
-
"consult --use <member>[,<member>]
|
|
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:
|
|
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
|
package/bin/tezgah-research
CHANGED
|
@@ -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(
|
|
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" %
|
|
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,
|
|
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
|
|
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)
|
|
@@ -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"]
|