@r1z4x/tezgah 1.4.0 → 1.5.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 CHANGED
@@ -6,6 +6,75 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.5.0] - 2026-10-09
10
+
11
+ ### Changed
12
+
13
+ - **A plan gets one review, at most two rounds.** The review reads the plan's
14
+ whole diff once, in its verification phase. Round one spawns its reviewers in
15
+ one message, one per dimension, or one reviewer for a diff under 300 lines.
16
+ Only a confirmed critical or major finding opens round two. Round two reads
17
+ the fix's delta. A minor finding goes on the fix-later list. `tezgah-task review`
18
+ refuses a third round. What is left after round two goes to the user. The
19
+ reviewer agent, the contract and plan-sync now state this one rule. Before,
20
+ the reviewer looped until a round confirmed nothing.
21
+ - **One Acceptance rule.** A plan enters a writing phase only when each
22
+ Acceptance item names its command or says `unverifiable` and why. Before, one
23
+ command-bearing item let the move through. `render_table --acceptance
24
+ --strict` then refused the same plan. Both now read one predicate,
25
+ `tezgah_task.unproven`, and the refusal names each item's line.
26
+ - **Typed decisions take any Jev carrier.** Taste's `learn`, `label`,
27
+ `calibrate`, `gate` and `measure --typed` require the Jev model, not
28
+ TypeSafe's address. So do taste's background start and replay's
29
+ `--label-model`. OpenRouter System One, Cloudflare Workers AI or a
30
+ compatible endpoint counts the same. Old `typesafe` rows keep counting.
31
+ `learn`'s report key `typesafe` is now `jev`. Replay's `--provider typesafe`
32
+ is now `--provider jev`, and the old name still works. The triage's no-judge
33
+ message names every carrier, the selection knob and the session CLIs.
34
+ - **The fan-out rule arms on the lists the owner writes.** Items written
35
+ `1 - ...`, `1- `, `1:`, `a)` or `+` now arm it. An inline `1. ... 2. ...`
36
+ arms it, and so does "subagentlerle". Three files named as one list arm it
37
+ too, and so do "First, ... Second, ..." and three imperative clauses. A later
38
+ list in the same session gets a line that names the action: count the items,
39
+ one subagent per item in one message. The rule covers every phase: a plan's
40
+ tasks, its acceptance commands and a survey's sources fan out too. The clause
41
+ that let a list of small items run one by one is gone.
42
+
43
+ ### Added
44
+
45
+ - **Plan order and progress in `tezgah-task status`.** A plan can name the
46
+ plans it waits on with `after:`. `status` prints each open plan's ticked
47
+ Acceptance boxes and what it still waits on. A `parallel:` line names the
48
+ ready plans whose `allowed_paths:` share no prefix. Those can run at once.
49
+ An open plan whose `status:` is not open or blocked is a `FAIL` line, and so
50
+ is an `after:` id no plan carries. `--json` adds `open` and `parallel`.
51
+ - **The judge asks opencode, Cursor and Codex sessions too.** The judgement
52
+ seam's session CLI was omp or claude only. It now also runs `opencode run`,
53
+ `cursor-agent -p` and `codex exec` headless when their host's marker is set
54
+ (`OPENCODE`, `CURSOR_AGENT`/`CURSOR_VERSION`, `CODEX_THREAD_ID`).
55
+ `TEZGAH_JUDGE_CLI`, else `judge_cli` in `config.json`, picks one of the five
56
+ by name. A picked CLI that is not installed means no session CLI.
57
+ - **Jev through any carrier.** The judge reaches Jev through TypeSafe,
58
+ OpenRouter, Cloudflare Workers AI or a System One-compatible URL
59
+ (`JEV_API_BASE_URL`, `JEV_API_KEY`).
60
+ `TEZGAH_JEV_PROVIDER`, `JEV_PROVIDER` or config.json `jev` picks the carrier,
61
+ and `tezgah-setup --jev <value>` writes the config key. `auto` asks every
62
+ carrier that resolves. A named carrier without its credential gets no
63
+ request, and no other carrier stands in. `tezgah-status --judge` prints the
64
+ choice and its carriers.
65
+ - **The omp login is a TypeSafe key.** With no `TYPESAFE_API_KEY` and no key
66
+ file, the seam reads the active omp login, read-only.
67
+ - **Clef and Clef-flash as typed judges.** Cloudflare's own decision models
68
+ answer the System One API on Workers AI with the Cloudflare token and account
69
+ id. `--jev clef` or `--jev clef-flash` picks one, and `auto` asks them after
70
+ the Jev carriers. A typed decision counts them like Jev. Cost rows now price
71
+ the carrier that answered: $0.24 per 1M input for Clef, $0.09 for Clef-flash.
72
+
73
+ ### Fixed
74
+
75
+ - **A spent key stops costing a request.** OpenRouter answers 403 to a key
76
+ past its limit. A 403 now marks a provider down for five minutes, like a 401.
77
+
9
78
  ## [1.4.0] - 2026-10-08
10
79
 
11
80
  ### Changed
@@ -864,7 +933,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
864
933
  the copy could not find `hooks/` and died on import. It now links into the
865
934
  installed tree, and also installs the command as `tezgah`.
866
935
 
867
- [Unreleased]: https://github.com/r1z4x/tezgah/compare/v1.4.0...HEAD
936
+ [Unreleased]: https://github.com/r1z4x/tezgah/compare/v1.5.0...HEAD
937
+ [1.5.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.5.0
868
938
  [1.4.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.4.0
869
939
  [1.3.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.3.0
870
940
  [1.2.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.2.0
@@ -3,7 +3,9 @@ name: tezgah-reviewer
3
3
  description: >
4
4
  Adversarial, read-only review of a diff, branch or PR: derives the blast
5
5
  radius, checks correctness, contract, security, tests and performance, and
6
- classifies every finding confirmed/refuted/unverified.
6
+ classifies every finding confirmed/refuted/unverified. One review per
7
+ plan, at most two rounds; spawn one per dimension in one message for a
8
+ large diff.
7
9
  model: opus
8
10
  effort: high
9
11
  readonly: true
@@ -56,15 +58,22 @@ is lowered only by evidence: a check you could not complete never
56
58
  downgrades a confirmed finding - keep its severity and mark it
57
59
  provisional. Disclose the order you read the files in.
58
60
 
59
- Scope and budget. Every round that confirms a defect is followed by a
60
- round over that fix's delta; the review ends when a round confirms none.
61
- A later round reads only the delta since the sha the previous round
62
- recorded (`tezgah-task review` prints it): re-reading the whole diff
63
- re-reports what was already triaged. Test results are input, not a task
64
- - read the author's output file; do not run the suite, and run at most one
65
- `-k` test when a specific accusation needs it. The same defect family
66
- confirmed three times is a sign the design needs a pivot, not another
67
- patch.
61
+ Scope and budget. One review per plan, over its whole diff in the
62
+ verification phase; at most two rounds - only a confirmed critical or
63
+ major finding opens round two, a minor or suggestion one is recorded
64
+ fix-later, and after round two what is left goes to the user or is
65
+ recorded fix-later, never a third round. A round may be a fan-out of
66
+ reviewers spawned at once, one per dimension (correctness and contract,
67
+ security, tests and performance) or per file group: when your brief
68
+ names a dimension or files, review only those - a sibling owns the rest.
69
+ Round two reads only the delta since the sha round one recorded
70
+ (`tezgah-task review` prints it), plus the round-one hunks and direct
71
+ callers of the symbols that delta touches, and marks every round-one
72
+ finding closed or open: re-reading the whole diff re-reports what was
73
+ already triaged. Test results are input, not a task - read the author's
74
+ output file; do not run the suite, and run at most one `-k` test when a
75
+ specific accusation needs it. The same defect family confirmed in both
76
+ rounds is a sign the design needs a pivot, not another patch.
68
77
 
69
78
  You may use: the codegraph MCP tools, read, grep and glob. Read-only:
70
79
  no writes, no edits, no shell.
package/bin/tezgah-docs CHANGED
@@ -24,7 +24,7 @@ every word is part of the query.
24
24
 
25
25
  Matching is word-based and case-insensitive against an entry's title, its
26
26
  questions, its sources and its path; every word must match, so a second word
27
- narrows. A query that matches nothing is the last resort of one TypeSafe Choice
27
+ narrows. A query that matches nothing is the last resort of one judge-seam Choice
28
28
  question over the pages themselves; it answers `none` for a query no page
29
29
  covers. When that question gives no judgement - unavailable (`docs-judge-off`,
30
30
  the seam's `judge-off`, no credential) or a call that failed or came back
@@ -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:667-928` (`CORE`).
109
+ # cell reads: `hooks/tezgah_policy.py:678-946` (`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
@@ -1036,7 +1036,7 @@ def record(result):
1036
1036
 
1037
1037
 
1038
1038
  def judge_pick(query):
1039
- """(judged, page): whether a TypeSafe Choice question came back with one of
1039
+ """(judged, page): whether a judge-seam Choice question came back with one of
1040
1040
  its options, and the page it named (None for `none`).
1041
1041
 
1042
1042
  The last resort for a query the index could not place: a word the index does
package/bin/tezgah-route CHANGED
@@ -12,8 +12,11 @@ A brief naming stored data, a migration, credentials, the gate or security goes
12
12
  tezgah-frontier by rule; otherwise Jev picks the tier (one Choice, ~650 input
13
13
  tokens, ~0.4 s); with no key or `judge-off`, `--phase`
14
14
  (explore|code|mechanical|plan|review|research) picks it from the static table, and
15
- with neither the middle tier is used. The brief is sent to api.typesafe.ai (or the
16
- OpenRouter fallback) - write it in English, the language the judge reads best.
15
+ with neither the middle tier is used. The brief is sent to the judge seam's
16
+ provider - the session's own CLI (omp, claude, opencode, cursor, codex) or the Jev
17
+ model through TypeSafe, OpenRouter System One, Cloudflare Workers AI or a
18
+ compatible endpoint (TEZGAH_JEV_PROVIDER picks one), else the OpenRouter chat
19
+ fallback - so write it in English, the language the judge reads best.
17
20
 
18
21
  Every routed call writes one `route` row to the session's ledger (TEZGAH_SESSION):
19
22
  tier, agent, what set the tier (`via`: rule|jev|phase|default), the static
package/bin/tezgah-setup CHANGED
@@ -884,6 +884,22 @@ def save_features(ids):
884
884
  return True
885
885
 
886
886
 
887
+ def set_jev(value):
888
+ """Persist `value` as config.json["jev"] - this key's only writer - and print
889
+ the carriers it resolves to now. An unchanged value writes nothing, like
890
+ `save_features`; a config that does not parse is refused, not replaced."""
891
+ import tezgah_judge
892
+ cfg = read_config(tp.CONFIG, {})
893
+ if cfg is None:
894
+ sys.stderr.write("tezgah-setup: %s is not valid JSON; jev not set\n" % tp.CONFIG)
895
+ return 1
896
+ if cfg.get("jev") != value:
897
+ cfg["jev"] = value
898
+ write_json(tp.CONFIG, cfg)
899
+ print("%s %s %s" % (OK, tp.CONFIG, tezgah_judge.jev_summary()))
900
+ return 0
901
+
902
+
887
903
  def app_servers(selected=None):
888
904
  """The app-analysis MCP servers to wire this run (shared spec)."""
889
905
  return tezgah_apps.servers(selection() if selected is None else selected)
@@ -4767,7 +4783,7 @@ def host_checks_omp():
4767
4783
  tp.have_typesafe_key()),
4768
4784
  # One row per channel: the two disagree in both directions - a key file
4769
4785
  # alone serves the seam, an omp login-store record alone serves omp.
4770
- ("judgement seam key resolvable (env or key file)",
4786
+ ("judgement seam key resolvable (session CLI, Jev carrier or OpenRouter)",
4771
4787
  tp.have_judge_key()),
4772
4788
  ("status line answers", omp_hook_answers()), ("tier models in task.agentModelOverrides and modelRoles.plan/slow", tier_models_current()),
4773
4789
  ]
@@ -5679,6 +5695,11 @@ def main(argv):
5679
5695
  help="with --install: the language replies are asked for and "
5680
5696
  "the Stop rule checks (config.json `reply_lang`; `tr` "
5681
5697
  "when unset, `any` checks none)")
5698
+ ap.add_argument("--jev", choices=tp.JEV_CHOICES,
5699
+ help="the carrier the judgement seam reaches the Jev model "
5700
+ "through (config.json `jev`; `auto` tries every one "
5701
+ "that resolves); TEZGAH_JEV_PROVIDER and JEV_PROVIDER "
5702
+ "still win over it")
5682
5703
  # `--version` is argparse's print-and-exit action, so `--upgrade --version V`
5683
5704
  # would print the current version, exit 0 and move nothing at all - the
5684
5705
  # "reads as done, did nothing" shape this repo hunts. Refused by name here,
@@ -5731,6 +5752,8 @@ def main(argv):
5731
5752
  if args.features:
5732
5753
  features_report()
5733
5754
  return 0
5755
+ if args.jev:
5756
+ return set_jev(args.jev)
5734
5757
  if args.refresh:
5735
5758
  paths = refresh_contract()
5736
5759
  for path in paths:
package/bin/tezgah-status CHANGED
@@ -298,7 +298,9 @@ def main():
298
298
  last = tj.last_use()
299
299
  if "--json" in flags:
300
300
  print(json.dumps(last, sort_keys=True))
301
- elif not last:
301
+ return 0
302
+ print(tj.jev_summary())
303
+ if not last:
302
304
  print("judge: no judgement recorded")
303
305
  elif last.get("provider"):
304
306
  print("judge: %s answered with %s/%s (fallback=%s)%s" % (
package/bin/tezgah-task CHANGED
@@ -5,7 +5,7 @@
5
5
  tezgah-task phase P [--any-path] change the active task's phase
6
6
  tezgah-task allow GLOB ... | --any-path replace the allowlist
7
7
  tezgah-task stop deactivate (clear phase)
8
- tezgah-task status [--json] the active task, or "no active task"
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
10
  tezgah-task close <NNN|path> done|discarded move a plan to .tezgah/plans/done
11
11
 
@@ -22,21 +22,27 @@ creates the plan, and `start` without `--allow` keeps it. A writing phase with
22
22
  no allowlist is refused unless `--any-path` says so explicitly, and the record
23
23
  keeps where its scope came from (`allowed_from:`), which `status` prints.
24
24
 
25
- `review` and `close` are the session's: the verification phase asks for a
26
- fresh reviewer's read of the diff, `review` records its verdict as
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
27
27
  `review: <reviewer> <verdict> <date>` together with `reviewed_sha:` (the
28
28
  project's HEAD it read) and `review_round:`, so the next round reads only
29
- `git diff <reviewed_sha>..HEAD` and the two-round budget is countable; `close
30
- NNN done` refuses a plan whose review is missing or not `approve`. `close`
31
- refuses the active task - that one is the user's to stop first.
29
+ `git diff <reviewed_sha>..HEAD`, and refuses a third round: the budget is two
30
+ 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
32
 
33
33
  Two things the plan owes are asked here, at the only moment they can still be
34
34
  fixed - the phase move - because the record is the user's and the refusal is
35
- this CLI's: a writing phase needs at least one Acceptance item that names the
36
- command proving it (or says `unverifiable` and why), since the report CI runs
37
- afterwards reads the same items and refuses the same plan too late; and
38
- `implementation` needs the plan's spike answered, so the build starts on
39
- evidence rather than on the guess the spike exists to retire.
35
+ this CLI's: a writing phase needs every Acceptance item to name the command
36
+ proving it (or say `unverifiable` and why), since the report CI runs afterwards
37
+ refuses an open plan on any one item that does neither, through the same
38
+ predicate (`tezgah_task.unproven`); and `implementation` needs the plan's spike
39
+ answered, so the build starts on evidence rather than on the guess the spike
40
+ exists to retire.
41
+
42
+ `status` lists every open plan after the active task: its Acceptance boxes
43
+ ticked, the plans its `after:` still waits on, and - on a `parallel:` line - the
44
+ ready plans whose `allowed_paths:` share no prefix, which can run at once. A
45
+ status outside open|blocked, or an `after:` id no plan carries, is a `FAIL` line.
40
46
 
41
47
  Entering `implementation` also records where the risky work returns to: a
42
48
  `checkpoint:` field holding the pre-work commit's sha, or `pending <sha>` while
@@ -139,6 +145,14 @@ def write_fields(path, **fields):
139
145
 
140
146
  ANY_PATH = "--any-path"
141
147
  VERDICTS = ("approve", "changes")
148
+ # The review budget, enforced here and nowhere else: `review` refuses a third
149
+ # round, and every text that states the rule (the reviewer agent, the contract,
150
+ # plan-sync) says the same two rounds.
151
+ REVIEW_ROUNDS = 2
152
+ # Under this many changed lines one reviewer reads every dimension; at or above
153
+ # it the round fans out. ponytail: a line count, not a risk model - a small diff
154
+ # that touches a trust boundary is the brief's to route to security.
155
+ SMALL_DIFF = 300
142
156
 
143
157
 
144
158
  def allow_source(text):
@@ -279,13 +293,14 @@ def phase_problems(phase, text, path):
279
293
  items = tt.acceptance_gap(text)
280
294
  if items:
281
295
  out.append(
282
- "%s cannot enter %s: all %d of its Acceptance items name no "
283
- "command and none declares `unverifiable` (read by "
284
- "hooks/tezgah_task.py:acceptance_items), so the work has no check "
285
- "to be held to. Give at least one item the command that proves "
296
+ "%s cannot enter %s: %d of its %d Acceptance items name no "
297
+ "command and declare no `unverifiable` (read by "
298
+ "hooks/tezgah_task.py:acceptance_items; at %s), so that work has "
299
+ "no check to be held to. Give each one the command that proves "
286
300
  "it, or `unverifiable` and why; "
287
- "`tezgah-render-table --acceptance` reports the same items"
288
- % (path, phase, len(items)))
301
+ "`tezgah-render-table --acceptance --strict` refuses the same items"
302
+ % (path, phase, len(items), len(tt.acceptance_items(text)),
303
+ ", ".join("%s:%d" % (path, i["line"]) for i in items)))
289
304
  if phase == "implementation" and tt.spike_unanswered(text):
290
305
  out.append(
291
306
  "%s cannot enter implementation: its spike is unanswered - `spike:` "
@@ -295,12 +310,23 @@ def phase_problems(phase, text, path):
295
310
  return out
296
311
 
297
312
 
313
+ def changed_lines(root, base):
314
+ """Lines `git diff <base>..HEAD` adds and removes, or None when git cannot
315
+ say (no base, not a repository)."""
316
+ stat = git_out(root, "diff", "--shortstat", base + "..HEAD") \
317
+ if base != "<base>" else None
318
+ if stat is None:
319
+ return None
320
+ return sum(int(n) for n in re.findall(r"(\d+) (?:insertion|deletion)", stat))
321
+
322
+
298
323
  def verification_steps(text, path):
299
324
  """What the verification phase requires before the plan is done: the plan's
300
- own acceptance commands run once on the final tree, unpiped, and a fresh
301
- reviewer's read of the diff, recorded with `review` - `close NNN done` refuses
302
- without it. A round after the first reads only the delta since the sha the
303
- last `review` recorded, and the review budget is two rounds."""
325
+ own acceptance commands run once on the final tree, unpiped, and ONE review
326
+ of the plan's whole diff, recorded with `review` - `close NNN done` refuses
327
+ without an approve. Round one fans out one reviewer per dimension, round two
328
+ reads only the delta since the sha round one recorded, and REVIEW_ROUNDS is
329
+ the budget `review` enforces."""
304
330
  fields = tt.frontmatter(text)
305
331
  ident = fields.get("id") or os.path.basename(path).split("-")[0]
306
332
  # every item, not only the ones the command reader recognises: `pnpm test`
@@ -311,27 +337,51 @@ def verification_steps(text, path):
311
337
  for i in tt.acceptance_items(text)]
312
338
  # the project, not the private repository `.tezgah` may hold
313
339
  root = os.path.realpath(os.path.join(os.path.dirname(path), "..", "..", ".."))
314
- reviewed = fields.get("reviewed_sha") or ""
340
+ reviewed = fields.get("reviewed_sha") or "(no commit)"
315
341
  prior = fields.get("review_round") or ""
316
342
  rounds = int(prior) if prior.isdigit() else 0
317
343
  print("verification - required before this plan is done:")
318
344
  print(" 1. prove the acceptance items ONCE, on the final tree - not again per "
319
345
  "review round - every command run unpiped "
320
- "(`cmd > /tmp/check.log 2>&1`, then read the file):")
346
+ "(`cmd > /tmp/check.log 2>&1`, then read the file); items that share "
347
+ "no state go out as tool calls in ONE message, one suite run at a time:")
321
348
  for item in items or ["(the plan has no Acceptance items)"]:
322
349
  print(" - %s" % item)
323
350
  if os.path.isfile(os.path.join(root, "tests", "impacted.py")):
324
351
  print(" the one full run here is `python3 tests/impacted.py --all`")
325
- if reviewed:
326
- print(" 2. review round %d: a fresh tezgah-reviewer reads only the delta "
327
- "`git diff %s..HEAD` and the test output above; it does not re-run "
328
- "the suite" % (rounds + 1, reviewed))
329
- if rounds >= 2:
330
- print(" the 2-round budget is spent: triage what is left as block "
331
- "(back to the user) or fix-later (recorded), never a third loop")
352
+ if rounds and review_verdict(text) == "approve":
353
+ print(" 2. the review approved in round %d at %s: no further round"
354
+ % (rounds, reviewed))
355
+ print(" 3. `tezgah-task close %s done` once the work lands" % ident)
356
+ return
357
+ if rounds >= REVIEW_ROUNDS:
358
+ 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)
362
+ return
363
+ if rounds:
364
+ print(" 2. review round 2 (the last): each confirmed critical or major "
365
+ "finding is fixed with a regression test that failed first; one "
366
+ "fresh tezgah-reviewer reads only the delta `git diff %s..HEAD`, the "
367
+ "round-1 hunks and direct callers of the symbols it touches, and the "
368
+ "test output above, and marks every round-1 finding closed or open; "
369
+ "it does not re-run the suite" % reviewed)
332
370
  else:
333
- print(" 2. a fresh reviewer - the host's tezgah-reviewer subagent - "
334
- "reads `git diff %s..HEAD` and the test output above" % diff_base(root))
371
+ base = diff_base(root)
372
+ size = changed_lines(root, base)
373
+ print(" 2. one review of the whole plan, at most %d rounds: spawn fresh "
374
+ "tezgah-reviewer subagents in ONE message, each owning one dimension "
375
+ "- correctness and contract, security, tests and performance - or one "
376
+ "file group of a large diff, over `git diff %s..HEAD` and the test "
377
+ "output above, then merge their findings into one verdict. Under %d "
378
+ "changed lines one reviewer reads every dimension (this diff: %s), "
379
+ "unless the diff touches auth, secrets or a gate, which keeps a "
380
+ "security reviewer" % (REVIEW_ROUNDS, base, SMALL_DIFF,
381
+ "unknown" if size is None else size))
382
+ print(" only a confirmed critical or major finding is `changes` and "
383
+ "opens round 2; a minor or suggestion one is recorded fix-later "
384
+ "under ## State and the verdict is `approve`")
335
385
  print(" 3. record its verdict: `tezgah-task review %s <reviewer> approve|changes`"
336
386
  " (records the reviewed sha);"
337
387
  " `tezgah-task close %s done` refuses without an approve" % (ident, ident))
@@ -495,6 +545,16 @@ def review_verdict(text):
495
545
  return parts[1] if len(parts) >= 2 and parts[1] in VERDICTS else None
496
546
 
497
547
 
548
+ 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))
556
+
557
+
498
558
  def cmd_review(args):
499
559
  if len(args) != 3 or args[2] not in VERDICTS:
500
560
  return misuse("review takes <NNN|path> <reviewer> %s" % "|".join(VERDICTS))
@@ -503,6 +563,8 @@ def cmd_review(args):
503
563
  return problems([problem])
504
564
  fields = tt.frontmatter(read(path))
505
565
  prior = fields.get("review_round") or ""
566
+ if prior.isdigit() and int(prior) >= REVIEW_ROUNDS:
567
+ return problems([budget_spent(path, os.path.basename(path).split("-")[0])])
506
568
  # the project's HEAD, never the private `.tezgah` repository's: the next
507
569
  # round's delta is `git diff <this>..HEAD` over the project
508
570
  root = os.path.realpath(os.path.join(os.path.dirname(path), "..", "..", ".."))
@@ -535,6 +597,9 @@ def cmd_close(args):
535
597
  return problems(["%s is the active task: the user stops it first "
536
598
  "(`tezgah-task stop`)" % path])
537
599
  if args[1] == "done" and review_verdict(text) != "approve":
600
+ prior = tt.frontmatter(text).get("review_round") or ""
601
+ if prior.isdigit() and int(prior) >= REVIEW_ROUNDS:
602
+ return problems([budget_spent(path, ident)])
538
603
  return problems(["%s records no approving review: a fresh reviewer reads "
539
604
  "the diff, then `tezgah-task review %s <reviewer> approve`"
540
605
  % (path, ident)])
@@ -561,22 +626,50 @@ def cmd_status(args):
561
626
  bad = []
562
627
  for path in plans(root):
563
628
  fields = tt.frontmatter(read(path))
629
+ ident = fields.get("id") or os.path.basename(path).split("-")[0]
564
630
  phase = fields.get("phase") or ""
565
631
  if phase and phase not in tt.TASK_PHASES:
566
- bad.append({"id": fields.get("id") or os.path.basename(path).split("-")[0],
567
- "phase": phase, "path": path})
632
+ bad.append({"id": ident, "phase": phase, "path": path,
633
+ "problem": "phase %r is not one of %s, so the plan is ignored (%s)"
634
+ % (phase, ", ".join(tt.TASK_PHASES), path)})
635
+ status = fields.get("status") or ""
636
+ if status not in tt.OPEN_STATUSES:
637
+ bad.append({"id": ident, "path": path,
638
+ "problem": "status %r is not one of %s, which is what a plan "
639
+ "under .tezgah/plans/open carries (%s)"
640
+ % (status, ", ".join(tt.OPEN_STATUSES), path)})
641
+ rows = tt.queue(root)
642
+ for row in rows:
643
+ for ident, state in row["after"]:
644
+ if state == "missing":
645
+ bad.append({"id": row["id"], "path": row["path"],
646
+ "problem": "`after: %s` names no plan under .tezgah/plans "
647
+ "(%s)" % (ident, row["path"])})
648
+ together = tt.parallel(rows)
568
649
  if task:
569
650
  task["allowed_from"] = allow_source(read(task["path"]))[1]
570
651
  if as_json:
571
- print(json.dumps({"active": task, "malformed": bad}, indent=2))
652
+ print(json.dumps({"active": task, "malformed": bad, "open": rows,
653
+ "parallel": together}, indent=2))
572
654
  else:
573
655
  if task:
574
656
  show(task["path"])
575
657
  else:
576
658
  print("no active task")
659
+ if rows:
660
+ print("open plans: %d, %d ready" % (len(rows), sum(r["ready"] for r in rows)))
661
+ for row in rows:
662
+ waits = ["%s (%s)" % (ident, state) for ident, state in row["after"]
663
+ if state != "done"]
664
+ print(" %s %s, acceptance %d/%d checked, %s" % (
665
+ row["id"], row["status"] or "(no status)", row["checked"], row["items"],
666
+ "ready" if row["ready"] else
667
+ "waits on %s" % ", ".join(waits) if waits else row["status"]))
668
+ if together:
669
+ print("parallel: %s - ready, and no two share an allowed_paths prefix"
670
+ % ", ".join(together))
577
671
  for row in bad:
578
- print("FAIL %s: phase %r is not one of %s, so the plan is ignored (%s)"
579
- % (row["id"], row["phase"], ", ".join(tt.TASK_PHASES), row["path"]))
672
+ print("FAIL %s: %s" % (row["id"], row["problem"]))
580
673
  return 1 if bad else 0
581
674
 
582
675