@r1z4x/tezgah 1.4.0 → 1.5.1

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 (43) hide show
  1. package/CHANGELOG.md +120 -1
  2. package/MANIFEST +0 -1
  3. package/README.md +6 -4
  4. package/bin/tezgah-docs +3 -3
  5. package/bin/tezgah-route +12 -6
  6. package/bin/tezgah-setup +67 -24
  7. package/bin/tezgah-status +3 -1
  8. package/bin/tezgah-task +132 -39
  9. package/bin/tezgah-taste +49 -31
  10. package/bin/tezgah-triage +12 -7
  11. package/docs/architecture.md +1 -1
  12. package/docs/clarity-baseline.json +7 -7
  13. package/docs/contract.md +21 -11
  14. package/docs/environment.md +3 -1
  15. package/docs/evidence.md +4 -3
  16. package/docs/glossary.md +1 -1
  17. package/docs/hosts.md +9 -0
  18. package/docs/judge.md +95 -27
  19. package/docs/layers.md +1 -1
  20. package/docs/models.md +17 -7
  21. package/docs/operations.md +23 -13
  22. package/docs/skills.md +1 -1
  23. package/docs/testing.md +1 -1
  24. package/docs/vision.md +10 -5
  25. package/hooks/tezgah_agents.py +149 -105
  26. package/hooks/tezgah_context.py +42 -7
  27. package/hooks/tezgah_judge.py +252 -79
  28. package/hooks/tezgah_models.py +50 -3
  29. package/hooks/tezgah_paths.py +180 -27
  30. package/hooks/tezgah_policy.py +41 -10
  31. package/hooks/tezgah_replay.py +26 -15
  32. package/hooks/tezgah_task.py +160 -46
  33. package/hooks/tezgah_taste.py +2 -2
  34. package/hooks/tezgah_taste_ledger.py +13 -4
  35. package/hosts/codex/hook.py +5 -0
  36. package/package.json +1 -2
  37. package/skills/harness/SKILL.md +4 -0
  38. package/skills/plan-add/SKILL.md +23 -7
  39. package/skills/plan-add/render_table.py +3 -2
  40. package/skills/plan-status/SKILL.md +12 -4
  41. package/skills/plan-sync/SKILL.md +19 -9
  42. package/skills/tezgah-contract/SKILL.md +29 -14
  43. package/agents/tezgah-reviewer.md +0 -70
@@ -21,14 +21,19 @@ before it happens is decided once. This module is on the gate's hot path for
21
21
  every write, so it imports nothing from tezgah_context and shells out to
22
22
  nothing.
23
23
 
24
- A plan's body is read here too, and only here: the acceptance report
24
+ A plan's body is read here too: the acceptance report
25
25
  (`tezgah-render-table --acceptance`) counts the items that name no command, so
26
26
  the record the gate reads and the items the report counts have one reader. Two
27
27
  readers of the body were added beside it and read in the same place - the
28
- `## Acceptance` items a plan cannot enter a writing phase without
29
- (`acceptance_gap`), and the spike a plan declares and has not answered
30
- (`spike_unanswered`) - for the same reason: the CLI refuses the phase move and
31
- the CI report counts the same items, and one module keeps them agreeing.
28
+ `## Acceptance` items a plan cannot enter a writing phase with while any of them
29
+ names no command (`acceptance_gap`, the predicate `--strict` refuses on too),
30
+ and the spike a plan declares and has not answered (`spike_unanswered`) - for
31
+ the same reason: the CLI refuses the phase move and the CI report counts the
32
+ same items, and one module keeps them agreeing. The open plans' progress (the
33
+ ticked boxes), the plans each one waits on (`after:`) and which ready plans can
34
+ run at once (`queue`, `parallel`) are read here for `tezgah-task status`. The
35
+ injected open-plans block keeps its own small reader
36
+ (`hooks/tezgah_context.py::_plan_row`: id, title, the first `## Next` line).
32
37
 
33
38
  The `checkpoint:` key is read here for the same reason again: bin/tezgah-task
34
39
  writes it when the phase moves to `implementation` - the pre-work commit's sha
@@ -100,8 +105,9 @@ def frontmatter(text):
100
105
  return out
101
106
 
102
107
 
103
- def allowed_paths(text):
104
- """The `allowed_paths:` list as a list of globs, [] when absent."""
108
+ def _list_field(text, key):
109
+ """One frontmatter key's `- item` block as a list of strings, [] when the key
110
+ is absent or carries no block."""
105
111
  out = []
106
112
  collecting = False
107
113
  for line in _frontmatter_lines(text):
@@ -110,10 +116,24 @@ def allowed_paths(text):
110
116
  out.append(stripped[2:].strip())
111
117
  continue
112
118
  collecting = (not stripped.startswith("- ") and ":" in stripped
113
- and stripped.partition(":")[0].strip() == "allowed_paths")
119
+ and stripped.partition(":")[0].strip() == key)
114
120
  return out
115
121
 
116
122
 
123
+ def allowed_paths(text):
124
+ """The `allowed_paths:` list as a list of globs, [] when absent."""
125
+ return _list_field(text, "allowed_paths")
126
+
127
+
128
+ def after(text):
129
+ """The plan ids this plan waits on (`after:`), in the order written: the
130
+ `- NNN` block `set_fields` writes, or the inline `after: 012, 015` a person
131
+ types. [] when the plan waits on nothing - which is what lets it run beside
132
+ the others."""
133
+ return (_list_field(text, "after")
134
+ or frontmatter(text).get("after", "").replace(",", " ").split())
135
+
136
+
117
137
  def section_lines(text, heading):
118
138
  """The lines of one `## <heading>` section with their 1-based numbers, []
119
139
  when the file has no such heading: the lines after the heading up to the next
@@ -129,7 +149,7 @@ def section_lines(text, heading):
129
149
  return out
130
150
 
131
151
 
132
- _ITEM = re.compile(r"^\s*- \[[ xX]\]\s*(.*)$")
152
+ _ITEM = re.compile(r"^\s*- \[([ xX])\]\s*(.*)$")
133
153
  # The word `unverifiable`, as a word: the lookahead is a boundary a hyphen and a
134
154
  # letter both fail, so `unverifiable-ness` is not a marker.
135
155
  _MARKER = re.compile(r"unverifiable(?![-\w])", re.I)
@@ -138,6 +158,12 @@ _MARKER = re.compile(r"unverifiable(?![-\w])", re.I)
138
158
  # why") has no separator there and is not a declaration.
139
159
  _SEPARATORS = ":,;(-"
140
160
  _SPAN = re.compile(r"`([^`\n]+)`")
161
+ # A runner whose test entry point is a bare subcommand: `pnpm test`, `cargo
162
+ # test`, `uv run pytest` carry no option and no path, yet each is the command.
163
+ # The runner has to be the first word, so `run the tests` stays prose.
164
+ _RUNNERS = ("npm", "pnpm", "yarn", "bun", "deno", "cargo", "go", "make", "mix",
165
+ "dotnet", "gradle", "mvn", "poetry", "uv")
166
+ _SUBCOMMANDS = ("test", "run")
141
167
 
142
168
 
143
169
  def _argument(token):
@@ -159,14 +185,16 @@ def _argument(token):
159
185
 
160
186
  def names_command(text):
161
187
  """Does this acceptance item name the command that proves it? A command is a
162
- backticked run of two or more words carrying an option, a path or a script:
163
- `bin/tezgah-docs --citations`, `python3 -m unittest tests/test_task.py`. A
164
- citation (`hooks/tezgah_task.py`, `plan:line`) is not one, and an invocation
165
- with no argument at all is not recognised either - both are reported, which
166
- is the safe direction for a report a person reads."""
188
+ backticked run of two or more words carrying an option, a path or a script
189
+ (`bin/tezgah-docs --citations`, `python3 -m unittest tests/test_task.py`), or
190
+ a known runner followed by `test` or `run` (`pnpm test`, `npm run test`). A
191
+ citation (`hooks/tezgah_task.py`, `plan:line`) is not one, and any other
192
+ invocation with no argument is not recognised either - both are reported,
193
+ which is the safe direction for a report a person reads."""
167
194
  for span in _SPAN.findall(text):
168
195
  tokens = span.split()
169
- if len(tokens) > 1 and any(_argument(token) for token in tokens[1:]):
196
+ if len(tokens) > 1 and (any(_argument(token) for token in tokens[1:]) or
197
+ (tokens[0] in _RUNNERS and tokens[1] in _SUBCOMMANDS)):
170
198
  return True
171
199
  return False
172
200
 
@@ -191,10 +219,11 @@ def unverifiable_reason(text):
191
219
 
192
220
  def acceptance_items(text):
193
221
  """One plan file's `## Acceptance` items: a list of
194
- {"line", "text", "state"}, plus "reason" when the state is "unverifiable".
195
- One field and not two - "checkable" (the item names a command),
196
- "unverifiable" (it says so and why) or "missing" (neither) - so no caller can
197
- read two answers about one item.
222
+ {"line", "text", "checked", "state"}, plus "reason" when the state is
223
+ "unverifiable". One field and not two - "checkable" (the item names a
224
+ command), "unverifiable" (it says so and why) or "missing" (neither) - so no
225
+ caller can read two answers about one item. "checked" is the box (`- [x]`),
226
+ which is the plan's progress and says nothing about the item's proof.
198
227
 
199
228
  An item is one `- [ ]`/`- [x]` line plus the lines it wraps onto, up to the
200
229
  next item or a blank line: the format wraps at 79 columns, so an item is more
@@ -204,8 +233,8 @@ def acceptance_items(text):
204
233
  for number, line in section_lines(text, "Acceptance"):
205
234
  found = _ITEM.match(line)
206
235
  if found:
207
- out.append({"line": number, "text": " ".join(found.group(1).split()),
208
- "state": "missing"})
236
+ out.append({"line": number, "text": " ".join(found.group(2).split()),
237
+ "checked": found.group(1) != " ", "state": "missing"})
209
238
  elif out and line.strip():
210
239
  out[-1]["text"] = " ".join((out[-1]["text"] + " " + line).split())
211
240
  for item in out:
@@ -217,19 +246,27 @@ def acceptance_items(text):
217
246
  return out
218
247
 
219
248
 
249
+ def unproven(items):
250
+ """The items that name no command and declare no `unverifiable` - the one
251
+ predicate both the phase move (`acceptance_gap`) and `render_table
252
+ --acceptance --strict` refuse on, so the two cannot let through different
253
+ plans."""
254
+ return [item for item in items if item["state"] == "missing"]
255
+
256
+
220
257
  def acceptance_gap(text):
221
- """The plan's Acceptance items when nothing in them can check the work: every
222
- item the section carries is `missing`, so none names a command and none
223
- declares itself `unverifiable`. [] otherwise, and [] when the plan has no
224
- Acceptance items at all.
258
+ """The plan's Acceptance items that nothing can check: each one names no
259
+ command and declares no `unverifiable`. [] when every item names its proof,
260
+ and [] when the plan has no Acceptance items at all.
225
261
 
226
- A plan with an empty or absent section is a different defect - the format asks
227
- plan-add to write the items, and the render_table report does not gate that
228
- case either - so refusing it here would tax every plan that predates the rule
229
- while the two readers still agreed about every item either of them can see.
230
- Returns the items so a refusal can count them."""
231
- items = acceptance_items(text)
232
- return items if items and all(item["state"] == "missing" for item in items) else []
262
+ Any such item is a gap, not only a section made of them: the format asks for
263
+ the proof per item, and the strict report refuses an open plan on one, so a
264
+ phase move that let one through would be refused later by the reader it was
265
+ meant to answer first. A plan with an empty or absent section is a different
266
+ defect - the format asks plan-add to write the items, and the report does not
267
+ gate that case either - so refusing it here would tax every plan that
268
+ predates the rule. Returns the items so a refusal can name them."""
269
+ return unproven(acceptance_items(text))
233
270
 
234
271
 
235
272
  # The `checkpoint:` values a plan can carry: a commit sha (the tree was clean at
@@ -295,6 +332,30 @@ def spike_unanswered(text):
295
332
 
296
333
 
297
334
  ACCEPTANCE_DIRS = ("open", "done")
335
+ # The `status:` a plan carries in each directory (plan-add's format): a plan
336
+ # under open/ is open or blocked, one under done/ is done or discarded.
337
+ OPEN_STATUSES = ("open", "blocked")
338
+
339
+
340
+ def _plan_files(root, sub):
341
+ """(name, path, text) for every readable `*.md` under
342
+ `<root>/.tezgah/plans/<sub>`, sorted by name; an absent directory or an
343
+ unreadable file is skipped, so a reader built on it never raises."""
344
+ directory = os.path.join(root, ".tezgah", "plans", sub)
345
+ try:
346
+ names = sorted(os.listdir(directory))
347
+ except OSError:
348
+ return
349
+ for name in names:
350
+ if not name.endswith(".md"):
351
+ continue
352
+ path = os.path.join(directory, name)
353
+ try:
354
+ with open(path, "r", encoding="utf-8") as fh:
355
+ text = fh.read()
356
+ except OSError:
357
+ continue
358
+ yield name, path, text
298
359
 
299
360
 
300
361
  def acceptance_report(root):
@@ -310,20 +371,7 @@ def acceptance_report(root):
310
371
  items = []
311
372
  plans = 0
312
373
  for sub in ACCEPTANCE_DIRS:
313
- directory = os.path.join(root, ".tezgah", "plans", sub)
314
- try:
315
- names = sorted(os.listdir(directory))
316
- except OSError:
317
- continue
318
- for name in names:
319
- if not name.endswith(".md"):
320
- continue
321
- path = os.path.join(directory, name)
322
- try:
323
- with open(path, "r", encoding="utf-8") as fh:
324
- text = fh.read()
325
- except OSError:
326
- continue
374
+ for name, _, text in _plan_files(root, sub):
327
375
  plans += 1
328
376
  for item in acceptance_items(text):
329
377
  item["plan"] = ".tezgah/plans/%s/%s" % (sub, name)
@@ -331,6 +379,72 @@ def acceptance_report(root):
331
379
  return {"plans": plans, "items": items}
332
380
 
333
381
 
382
+ def _plan_id(fields, name):
383
+ return fields.get("id") or name.split("-")[0]
384
+
385
+
386
+ def _prefix(glob):
387
+ """The literal head of a glob: everything before its first `*`."""
388
+ return glob.split("*", 1)[0]
389
+
390
+
391
+ def scopes_overlap(first, second):
392
+ """Could two allowlists name one file? An empty list is every path, and two
393
+ globs can only meet when one literal head is a prefix of the other's - so
394
+ `hooks/a*.py` and `hooks/b*.py` never do, and `**/x` meets everything.
395
+ ponytail: prefix test, not glob intersection - it says "overlap" for
396
+ `hooks/*.py` against `hooks/x/y.md`, which only costs a missed pairing."""
397
+ if not first or not second:
398
+ return True
399
+ return any(a.startswith(b) or b.startswith(a)
400
+ for a in map(_prefix, first) for b in map(_prefix, second))
401
+
402
+
403
+ def queue(root):
404
+ """Every open plan as a row: {"id", "status", "path", "checked", "items" (the
405
+ Acceptance boxes ticked, and their count), "after" ([id, state] per plan it
406
+ waits on: `done`, `discarded`, `open` or `missing` when no plan carries the
407
+ id), "ready" (not blocked, and everything it waits on is done), "overlaps"
408
+ (the other open plans whose allowed_paths can name one of its files)}.
409
+
410
+ The plans' progress and order had no reader: the boxes were counted nowhere
411
+ and nothing said which plans could run beside each other. Never raises."""
412
+ state = {}
413
+ for name, _, text in _plan_files(root, "done"):
414
+ fields = frontmatter(text)
415
+ state[_plan_id(fields, name)] = ("discarded" if fields.get("status") == "discarded"
416
+ else "done")
417
+ rows = []
418
+ scopes = {}
419
+ for name, path, text in _plan_files(root, "open"):
420
+ fields = frontmatter(text)
421
+ ident = _plan_id(fields, name)
422
+ state[ident] = "open"
423
+ items = acceptance_items(text)
424
+ scopes[ident] = allowed_paths(text)
425
+ rows.append({"id": ident, "status": fields.get("status", ""), "path": path,
426
+ "checked": sum(1 for item in items if item["checked"]),
427
+ "items": len(items), "after": after(text)})
428
+ for row in rows:
429
+ row["after"] = [[ident, state.get(ident, "missing")] for ident in row["after"]]
430
+ row["ready"] = (row["status"] != "blocked"
431
+ and all(dep == "done" for _, dep in row["after"]))
432
+ row["overlaps"] = [other["id"] for other in rows if other is not row
433
+ and scopes_overlap(scopes[row["id"]], scopes[other["id"]])]
434
+ return rows
435
+
436
+
437
+ def parallel(rows):
438
+ """The ready plans that can run at once, in id order: each one shares no
439
+ allowed_paths prefix with any other picked (greedy, first id first). [] when
440
+ fewer than two qualify - one plan is not a fan-out."""
441
+ picked = []
442
+ for row in rows:
443
+ if row["ready"] and not any(other in row["overlaps"] for other in picked):
444
+ picked.append(row["id"])
445
+ return picked if len(picked) > 1 else []
446
+
447
+
334
448
  def active(cwd, base):
335
449
  """The active task as a dict, or None. Scans `<repo_root>/.tezgah/plans/open/*.md`
336
450
  sorted by name, first file whose frontmatter carries a valid `phase`.
@@ -218,7 +218,7 @@ def learn_later(root):
218
218
  signals of `root` (the git root `enabled` returned) grew since the last
219
219
  start and an hour has passed; the stamp (a `taste_learn` document of the
220
220
  cache database) keeps the newest signal's append number. Only when
221
- TypeSafe resolves: the automatic run never pays a
221
+ the Jev model resolves through any carrier: the automatic run never pays a
222
222
  generative provider for a decision it may not apply. Not on a
223
223
  machine without flock (Windows), where two background runs in two
224
224
  repositories would race on the user ledger. ponytail: no Windows lock; add
@@ -243,7 +243,7 @@ def learn_later(root):
243
243
  import tezgah_judge
244
244
  import tezgah_taste_ledger
245
245
  if tezgah_taste_ledger.fcntl is None or tezgah_paths.off("judge-off") \
246
- or not tezgah_judge.named(("typesafe",)):
246
+ or not tezgah_judge.named(("jev",)):
247
247
  return
248
248
  if not store.put_doc("taste_learn", name, {"rows": count, "at": now}):
249
249
  return
@@ -135,6 +135,15 @@ def rows(root, table, where="", args=()):
135
135
  return store.rows(db, table, where, args) if db else []
136
136
 
137
137
 
138
+ def jev_rows():
139
+ """(where, args) for `rows`: the rows a Jev carrier (`tezgah_judge.JEV_CARRIERS`)
140
+ decided, whichever one answered - an old `typesafe` row included. Imported
141
+ here, not at the top: the write hook loads this module and never asks."""
142
+ import tezgah_judge
143
+ carriers = tuple(tezgah_judge.JEV_CARRIERS)
144
+ return "WHERE provider IN (%s)" % ", ".join("?" * len(carriers)), carriers
145
+
146
+
138
147
  def append(root, table, row, strict=False):
139
148
  """One row into a taste table. A hook drops a row it cannot write; `strict`
140
149
  (the CLI's decision, defect and label rows) raises instead, so `learn` stops
@@ -193,10 +202,10 @@ class locked:
193
202
  def calibration(root):
194
203
  """(x, n, bound): of the labelled signals the typed decision called a
195
204
  preference, how many the label agrees with, and the Clopper-Pearson lower
196
- bound on that precision. Only typed-provider decisions count; the labels are
205
+ bound on that precision. Only Jev decisions count; the labels are
197
206
  a sample drawn independently of what was injected (`tezgah-taste label`)."""
198
207
  x = n = 0
199
- for row in rows(root, "labels", "WHERE provider = ?", ("typesafe",)):
208
+ for row in rows(root, "labels", *jev_rows()):
200
209
  if row.get("decided") != "preference":
201
210
  continue
202
211
  n += 1
@@ -258,7 +267,7 @@ def apply(ledgers, sig, dec, day, applied=()):
258
267
 
259
268
  `dec` is the typed decision: `kind`, `category`, `scope` as (choice,
260
269
  probability), `relations` {learning id: (relation, probability)} and
261
- `verified`, True only when TypeSafe answered - any other provider's decision
270
+ `verified`, True only when the Jev model answered - any other provider's decision
262
271
  is never applied. `applied` are the ids injected in the signal's session."""
263
272
  if not dec.get("verified"):
264
273
  return "unverified"
@@ -571,7 +580,7 @@ def gate(root, need=980, alpha=0.1):
571
580
  lower at one-sided p < `alpha`. Returns the report."""
572
581
  first = first_injection(root)
573
582
  arms = {"before": [0, 0], "after": [0, 0]}
574
- for row in rows(root, "decisions", "WHERE provider = ?", ("typesafe",)):
583
+ for row in rows(root, "decisions", *jev_rows()):
575
584
  at = row.get("at")
576
585
  if not isinstance(at, (int, float)):
577
586
  continue
@@ -158,6 +158,11 @@ def main():
158
158
  event = payload.get("hook_event_name") or "SessionStart"
159
159
  cwd = payload.get("cwd") or os.getcwd()
160
160
  session_id = payload.get("session_id")
161
+ # codex hands its hooks its own env, which carries no codex marker (only the
162
+ # shell tool gets CODEX_THREAD_ID), so the judge seam's session_cli() could
163
+ # not tell this is a codex session without it.
164
+ if session_id:
165
+ os.environ.setdefault("CODEX_THREAD_ID", str(session_id))
161
166
  safe(session_id, bind_session, session_id) # the switch latch (tezgah_paths.off)
162
167
  if event == "SessionStart":
163
168
  safe(session_id, attest_session, "codex", session_id, cwd)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r1z4x/tezgah",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "One shared working contract for every AI coding assistant you run: Claude Code, Codex, Cursor, opencode, dsh and oh-my-pi (omp).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,7 +19,6 @@
19
19
  "output-styles/",
20
20
  "commands/",
21
21
  "docs/",
22
- "agents/",
23
22
  "statusline.py",
24
23
  "MANIFEST",
25
24
  "CHANGELOG.md",
@@ -60,6 +60,10 @@ is reported as such, and an empty list beside it is not evidence of absence.
60
60
  `graph-review` splits findings into `confirmed` (no refuter refuted it), `refuted` (do not report as bugs),
61
61
  and `unverified` (hit the cap, or every refuter failed - label them as such). Never promote a refuted or unverified finding.
62
62
 
63
+ `graph-review` is one review round, not a loop: inside a plan it is round one or round two of the
64
+ plan's single review (two rounds at most, `tezgah-task review` refuses a third), never one run per
65
+ task, and never re-run on a diff it already read.
66
+
63
67
  `graph-impact`'s `graph_blind_spots` are call sites the graph could not see - string dispatch, config,
64
68
  templates, generated code. They are usually where a migration actually breaks. Lead with them.
65
69
 
@@ -40,17 +40,23 @@ Input: `$ARGUMENTS` (free text describing the work). Run these steps in order.
40
40
  Acceptance: checkable criteria, each with the command that proves it - or the
41
41
  word `unverifiable` and why, since *when possible* is the loophole
42
42
  (`~/.config/tezgah/bin/tezgah-render-table --acceptance` reports the items that
43
- name no command). A plan whose every item names no command cannot enter a
44
- writing phase at all (`tezgah-task` refuses it), so this is the plan's own
45
- check, not a report read later. State: `not started` plus the findings from
46
- step 4. Next: the first concrete action. Dates: `date +%F`. `allowed_paths:`:
43
+ name no command). An item that names no command keeps the plan out of a
44
+ writing phase (`tezgah-task` refuses it, through the predicate `--strict`
45
+ refuses on), so this is the plan's own check, not a report read later. State:
46
+ `not started` plus the findings from
47
+ step 4. Next: the first concrete action - when the plan holds two or more
48
+ independent tasks, one parallel wave: one slice per task, each with its own
49
+ worktree and a subset of `allowed_paths:`, spawned in one message. Dates: `date +%F`. `allowed_paths:`:
47
50
  the globs the work will write, from the files step 4 found (`apps/admin/**`,
48
51
  `packages/ui/src/**`), narrow enough that a write outside them is a surprise
49
52
  - the plan names its own scope, and `tezgah-task start` without `--allow`
50
53
  holds the task to it. A plan that has to spike an assumption first (does the
51
54
  approach work at all?) writes the four `spike:` keys below: `implementation`
52
55
  is refused while `spike_recorded:` names no answer, so the answer is recorded
53
- before the build rather than after it.
56
+ before the build rather than after it. `after:`: the ids of the open plans
57
+ this one cannot start before (`after: 012, 015`), only when step 4 found a
58
+ real dependency - a plan with none can run beside the others, and
59
+ `tezgah-task status` names which ready plans share no `allowed_paths:` prefix.
54
60
  6. Rewrite the README status table: `~/.config/tezgah/bin/tezgah-render-table` (installed by `bin/tezgah-setup --install`; if it is missing, run that script)
55
61
  (one row per open plan, sorted by id; it prints the rows). A new plan has no PR
56
62
  state to enrich, so no `--pr-info` here; that enrichment belongs to plan-status.
@@ -72,7 +78,7 @@ Input: `$ARGUMENTS` (free text describing the work). Run these steps in order.
72
78
  user passes `--any-path`, so tell the user the exact command - the plan's
73
79
  scope included - and never suggest dropping it. Three more refusals land at
74
80
  the same moment, and the user fixes them in the plan, not the command: a
75
- writing phase whose every Acceptance item names no command, and
81
+ writing phase while any Acceptance item names no command, and
76
82
  `implementation` while the plan's spike is unanswered (both refused with the
77
83
  file and the reader that reported it); and `implementation` is refused while
78
84
  the tree is dirty, because the phase starts by recording `checkpoint:` - the
@@ -111,6 +117,7 @@ created: YYYY-MM-DD
111
117
  updated: YYYY-MM-DD
112
118
  allowed_paths:
113
119
  - <glob>
120
+ after:
114
121
  ---
115
122
  ## Goal
116
123
  <1-3 sentences>
@@ -134,6 +141,14 @@ acceptance commands run unpiped, and a fresh reviewer (the host's
134
141
  `tezgah-task review NNN <reviewer> approve|changes` records as `review:`.
135
142
  `tezgah-task close NNN done` (plan-sync) refuses without an `approve`.
136
143
 
144
+ `after:` names the plans that must be done before this one starts - a `- NNN`
145
+ block or `after: 012, 015`. `tezgah-task status` reads it: each open plan's
146
+ Acceptance boxes ticked (`acceptance 3/8 checked`), what it still waits on
147
+ (`waits on 012 (open)`), and a `parallel:` line naming the ready plans whose
148
+ `allowed_paths:` share no prefix, which can run at once in separate worktrees.
149
+ An `after:` id no plan carries, or an open plan whose `status:` is not open or
150
+ blocked, is a `FAIL` line there.
151
+
137
152
  `checkpoint:` is written by `tezgah-task` when the phase moves to
138
153
  `implementation`: the sha of the commit the work returns to, or `pending` while
139
154
  the tree still holds work no commit names - and while it reads `pending` the gate
@@ -163,7 +178,8 @@ Small self-contained edits do not get a plan.
163
178
  `allowed_paths` (the plan's scope), `phase` while it is the active task,
164
179
  `checkpoint` in the implementation phase (the commit the work returns to) and
165
180
  `review` once a fresh reviewer read its diff, plus the optional `spike` keys
166
- on a plan that answers one question before building,
181
+ on a plan that answers one question before building and `after` (the plan ids
182
+ it waits on),
167
183
  sections Goal, Acceptance (checkboxes: each item carries the command that proves
168
184
  it, or `unverifiable` and why), State (evidence), Next (one action or BLOCKED).
169
185
  - Work for a plan happens on branch `plan/NNN-slug`; a merged PR (or a branch merged
@@ -37,8 +37,9 @@ def acceptance(strict):
37
37
  count = lambda state: sum(1 for i in report["items"] if i["state"] == state)
38
38
  print("plans: %d read, acceptance items: %d read; %d name no command, %d declared unverifiable"
39
39
  % (report["plans"], len(report["items"]), count("missing"), count("unverifiable")))
40
- open_missing = [i for i in report["items"]
41
- if i["state"] == "missing" and i["plan"].startswith(".tezgah/plans/open/")]
40
+ # the predicate the phase move refuses on (tezgah-task), so the two agree
41
+ open_missing = tt.unproven([i for i in report["items"]
42
+ if i["plan"].startswith(".tezgah/plans/open/")])
42
43
  if strict and open_missing:
43
44
  print("strict: %d item(s) under .tezgah/plans/open name no command and declare no "
44
45
  "unverifiable; a done plan is reported, never gated" % len(open_missing))
@@ -15,7 +15,12 @@ description: >
15
15
  1. Resolve the root: `ROOT=$(git rev-parse --show-toplevel)`. If it fails, stop and
16
16
  say plans need a git repo. If `$ROOT/.tezgah/plans/open` is missing, say so and stop.
17
17
  2. For each `.tezgah/plans/open/*.md`: read the frontmatter (id, title, status, branch, pr)
18
- and the first line of the `## Next` section.
18
+ and the first line of the `## Next` section. Then run
19
+ `~/.config/tezgah/bin/tezgah-task status`: per open plan it prints the
20
+ Acceptance boxes ticked, the plans its `after:` still waits on, and a
21
+ `parallel:` line naming the ready plans that share no `allowed_paths:`
22
+ prefix; a `FAIL` line (a status outside open|blocked, an `after:` id no plan
23
+ carries, a phase no reader accepts) goes into the report as it printed.
19
24
  3. If `pr:` is set, run
20
25
  `gh pr view <pr> --json state,mergedAt,reviewDecision,statusCheckRollup -q '[.state, .reviewDecision, ([.statusCheckRollup[]?.conclusion] | unique | join(","))] | map(select(. != null and . != "")) | join(" | ")'`
21
26
  and append the result to that row's `pr` cell, e.g. `#12 (OPEN | APPROVED | SUCCESS)`.
@@ -37,16 +42,19 @@ description: >
37
42
  no "Generated with" / "Made with", no robot emoji, no Claude/Anthropic/OpenAI/
38
43
  GPT/Codex/Gemini/Cursor/Copilot credit.
39
44
  8. Print the table, then one line per plan with `status: blocked` quoting its
40
- `BLOCKED: <reason>`, then any gh failures.
45
+ `BLOCKED: <reason>`, then one line per plan that waits on another (`waits on
46
+ NNN (open)`), then any gh failures and `FAIL` lines.
41
47
  9. End with exactly one recommendation: "Work on NNN <slug> next: <reason>". No menu.
42
48
  Prefer plans with an approved PR and green CI, then open plans with no blocker,
43
- oldest first.
49
+ oldest first; never a plan that still waits on another. When the `parallel:`
50
+ line names it with others, add one sentence: those plans can run at once, one
51
+ worktree each.
44
52
 
45
53
  ## Format
46
54
 
47
55
  Plan file `.tezgah/plans/open/NNN-slug.md` (status open|blocked) or `.tezgah/plans/done/NNN-slug.md`
48
56
  (status done|discarded). Frontmatter: id, title, status, branch (`plan/NNN-slug`),
49
- pr, created, updated, allowed_paths, plus phase/allowed_from/review when set. Sections: `## Goal`, `## Acceptance` (checkboxes), `## State`
57
+ pr, created, updated, allowed_paths, plus after/phase/allowed_from/checkpoint/review/review_round/reviewed_sha and the spike keys when set. Sections: `## Goal`, `## Acceptance` (checkboxes), `## State`
50
58
  (evidence), `## Next` (one action or `BLOCKED: <reason>`). README table lives between
51
59
  `<!-- status:start -->` and `<!-- status:end -->` with columns
52
60
  `| id | title | status | branch | pr | next |`.
@@ -28,16 +28,23 @@ description: >
28
28
  - gh returned `[]`: fallback `git branch -r --merged origin/main | grep -q "origin/<branch>"`.
29
29
  If it matches, treat as done and append `merged without PR`.
30
30
  - otherwise: leave the plan untouched.
31
- 5. For every plan marked done: if `## Acceptance` still has `- [ ]` boxes, still move
32
- it but append the line `acceptance boxes unchecked at sync` to `## State`.
31
+ 5. For every plan marked done: if `## Acceptance` still has `- [ ]` boxes (the
32
+ `acceptance N/M checked` count `~/.config/tezgah/bin/tezgah-task status` prints
33
+ per open plan), still move it but append the line
34
+ `acceptance boxes unchecked at sync (N/M checked)` to `## State`.
33
35
  6. Move each done or discarded plan with
34
36
  `~/.config/tezgah/bin/tezgah-task close NNN done|discarded`: it sets `status:` and
35
37
  `updated:` and moves the file to `.tezgah/plans/done/`. It refuses `done` unless the
36
- plan records `review: <reviewer> approve` - a fresh reviewer (the host's
37
- `tezgah-reviewer` subagent) read `git diff <base>..HEAD` and
38
- `tezgah-task review NNN <reviewer> approve` wrote the verdict - and it refuses
39
- the active task. A refused plan stays open: report it with the refusal, never
40
- move it by hand.
38
+ plan records `review: <reviewer> approve` - fresh reviewers (the host's
39
+ `tezgah-reviewer` subagent, one per dimension in one message for a large diff)
40
+ read `git diff <base>..HEAD` and `tezgah-task review NNN <reviewer> approve`
41
+ wrote the verdict - and it refuses the active task. A refused plan stays open:
42
+ report it with the refusal, never move it by hand, and never start a review
43
+ round from here. One review per plan, over its whole diff in the verification
44
+ phase; at most two rounds - only a confirmed critical or major finding opens
45
+ round two, a minor or suggestion one is recorded fix-later, and after round two
46
+ what is left goes to the user or is recorded fix-later, never a third round.
47
+ A plan refused with a spent budget is the user's decision.
41
48
  7. Rewrite the README status table: `~/.config/tezgah/bin/tezgah-render-table` (installed by `bin/tezgah-setup --install`; if it is missing, run that script)
42
49
  (one row per open plan, sorted by id; it prints the rows). No `--pr-info` here:
43
50
  the row's `pr` cell comes from the plan files, and review/check enrichment
@@ -55,14 +62,17 @@ description: >
55
62
  no "Generated with" / "Made with", no robot emoji, no Claude/Anthropic/OpenAI/
56
63
  GPT/Codex/Gemini/Cursor/Copilot credit.
57
64
  9. Report four lists: moved plans (with done/discarded and the evidence line),
58
- plans `close` refused (with its reason), still-open plans, and gh failures. Never delete branches, never edit code outside
65
+ plans `close` refused (with its reason), still-open plans - each with what
66
+ `tezgah-task status` says it waits on, so a plan whose `after:` names one just
67
+ discarded (`waits on NNN (discarded)`) is named for the user to re-plan - and
68
+ gh failures. Never delete branches, never edit code outside
59
69
  `.tezgah/plans/`.
60
70
 
61
71
  ## Format
62
72
 
63
73
  Plan file `.tezgah/plans/open/NNN-slug.md` (status open|blocked) or `.tezgah/plans/done/NNN-slug.md`
64
74
  (status done|discarded). Frontmatter: id, title, status, branch (`plan/NNN-slug`),
65
- pr, created, updated, allowed_paths, plus phase/allowed_from/review when set.
75
+ pr, created, updated, allowed_paths, plus after/phase/allowed_from/checkpoint/review/review_round/reviewed_sha and the spike keys when set.
66
76
  Sections: `## Goal`, `## Acceptance` (checkboxes), `## State`
67
77
  (evidence), `## Next` (one action or `BLOCKED: <reason>`). README table lives between
68
78
  `<!-- status:start -->` and `<!-- status:end -->` with columns