@r1z4x/tezgah 1.7.0 → 1.8.2

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.
@@ -484,7 +484,8 @@ PERMANENT_ERROR = re.compile(
484
484
  # `turn` row carries when a host hands the refusal back as a prompt (plan 083).
485
485
  # `open`, `ready` and `blocked` are a `todo` row's counts of the host's todo list
486
486
  # (`note_todo`), and `heads` the list of its ready items' heads, each redacted:
487
- # `detail` holds the same heads, one per line, under the row's cut.
487
+ # `detail` holds the same heads, one per line, under the row's cut. `taken` is
488
+ # the in-progress items' heads, and `partial` marks a Cursor `merge: true` list.
488
489
  # `residual` is a `subagent_end` row's count of residual items no blocker type
489
490
  # accounts for (`unblocked_items`): the parent's Stop owes them (S4 finding 2).
490
491
  # `questions` and `defaults` are an `ask` row's counts (`note_ask`): the
@@ -501,7 +502,7 @@ LEDGER_FIELDS = frozenset(("id", "exit", "out_bytes", "fail_class", "workspace",
501
502
  "check", "key", "block", "repo", "empty_run", "cause",
502
503
  "host", "switches", "harness", "authorized", "inflight",
503
504
  "reask", "open", "ready", "blocked", "heads", "residual",
504
- "questions", "defaults"))
505
+ "questions", "defaults", "taken", "partial"))
505
506
 
506
507
  # The row contract's own version, stamped by the writer beside `kind` and `ts` so
507
508
  # it is not a caller field. It exists because a row is read back to decide a
@@ -3279,15 +3280,16 @@ TODO_HEAD = 60
3279
3280
  def note_todo(session_id, tool, inp, snapshot=None, cwd=None, agent=None):
3280
3281
  """Write one `todo` row for a todo call: the `open`, `ready` and `blocked`
3281
3282
  counts, every ready item's head in `heads` (redacted, TODO_HEAD characters
3282
- each) and the same heads in `detail`, one per line, which the row's cut may
3283
- shorten. A call whose list is not in hand (an omp `todo` result without
3284
- its `details`) writes nothing, so it cannot read as an empty queue. An item
3285
- whose own text types a blocker (KARAR, YETKİ, GERİ ALINAMAZ, ORTAM, or
3286
- `blocked`) counts as blocked: Claude's and Codex's lists have no blocked
3287
- status. `agent` is the host's subagent id: a subagent's list is its own,
3288
- and the readers skip it. ponytail: a Cursor `merge: true` call carries
3289
- only the items it changes and is recorded as the whole list, so its counts
3290
- can run low."""
3283
+ each), every in-progress item's head in `taken`, and the ready heads in
3284
+ `detail`, one per line, which the row's cut may shorten. A call whose list
3285
+ is not in hand (an omp `todo` result without its `details`) writes nothing,
3286
+ so it cannot read as an empty queue. An item whose own text types a blocker
3287
+ (KARAR, YETKİ, GERİ ALINAMAZ, ORTAM, or `blocked`) counts as blocked:
3288
+ Claude's and Codex's lists have no blocked status. `agent` is the host's
3289
+ subagent id: a subagent's list is its own, and the readers skip it. A
3290
+ Cursor `merge: true` call carries only the items it changes: its row is
3291
+ marked `partial`, which the `open todo` class skips. ponytail: the counts
3292
+ of a partial row can still run low for `ready steps`."""
3291
3293
  raw = snapshot if snapshot is not None else inp.get("todos", inp.get("plan"))
3292
3294
  if not isinstance(raw, list):
3293
3295
  return
@@ -3299,12 +3301,18 @@ def note_todo(session_id, tool, inp, snapshot=None, cwd=None, agent=None):
3299
3301
  blocked = [text for text, status in items if status == "blocked" or
3300
3302
  BLOCKER_SAID.search(text) or BLOCKER_TRIED.search(text)
3301
3303
  or re.search(r"\bblocked\b", text, re.I)]
3302
- heads = [_stored_text(" ".join(text.split()))[:TODO_HEAD] for text, status in items
3304
+
3305
+ def head(text):
3306
+ return _stored_text(" ".join(text.split()))[:TODO_HEAD]
3307
+ heads = [head(text) for text, status in items
3303
3308
  if status == "pending" and text not in blocked]
3309
+ taken = [head(text) for text, status in items
3310
+ if status != "pending" and text not in blocked]
3304
3311
  note(session_id, "todo", "\n".join(heads),
3305
3312
  id=call_id(tool, inp), tool=str(tool or "").strip() or None,
3306
3313
  workspace=root_for(cwd) if cwd else None, agent=agent or None,
3307
- open=len(items), ready=len(heads), blocked=len(blocked), heads=heads)
3314
+ open=len(items), ready=len(heads), blocked=len(blocked), heads=heads,
3315
+ taken=taken, partial=True if snapshot is None and inp.get("merge") else None)
3308
3316
 
3309
3317
 
3310
3318
  def note_tool(session_id, tool, inp, failed=None, *, interrupted=False,
@@ -5550,6 +5558,16 @@ READY_STEPS = (
5550
5558
  "or named in the reply on one line with its {DECISION}, {ACCESS}, {IRREVERSIBLE} "
5551
5559
  "or {ENV}. End the turn only when no step is ready.")
5552
5560
 
5561
+ # The `open todo` class (plan 097): the reply claims done while the newest todo
5562
+ # list still holds an in-progress item, not blocked (`open_left`). Filled with
5563
+ # the count and up to three heads.
5564
+ OPEN_TODO = (
5565
+ "The reply claims done, but the newest todo list still holds %d item(s) in "
5566
+ "progress: %s. Finish them and mark them done in the todo list, or say "
5567
+ "plainly which are left and why - each on one line with its {DECISION}, "
5568
+ "{ACCESS}, {IRREVERSIBLE} or {ENV}, or marked blocked in the todo list "
5569
+ "with its reason.")
5570
+
5553
5571
 
5554
5572
  def _tr_lower(text):
5555
5573
  """`text` lowercased the Turkish way (I -> ı, İ -> i): `str.lower` reads
@@ -6019,7 +6037,7 @@ def _residual_block(text, rows, shape, verdict=(None, None), session_id=None,
6019
6037
  the `needless ask` class when `needless_ask` names the question the reply
6020
6038
  ends on (any turn, worked or not: the plan proposal that ends "Başlayayım
6021
6039
  mı?" is its measured case); else the `ready steps` class when `ready_left`
6022
- names one; else the
6040
+ names one; else the `open todo` class when `open_left` names one; else the
6023
6041
  `stopped slice` class when `_stopped_slice` finds one; else `verdict`. Not
6024
6042
  asked for a subagent's end (`shape` false) or a nested session: both
6025
6043
  replies are read by code, not by the user (`_shape_block`). Residual work
@@ -6053,10 +6071,15 @@ def _residual_block(text, rows, shape, verdict=(None, None), session_id=None,
6053
6071
  asked = needless_ask(text, lambda: grants_in_force(session_id))
6054
6072
  if asked:
6055
6073
  return ("needless ask", NEEDLESS_ASK % cut(asked, 80))
6056
- ready = ready_left(text, rows, session_id, cwd)
6074
+ todo = _turn_todo(rows, session_id)
6075
+ ready = _ready_in(text, todo, session_id, cwd)
6057
6076
  if ready:
6058
6077
  return ("ready steps", READY_STEPS % (
6059
6078
  len(ready), "; ".join(cut(head, 48) for head in ready[:3])))
6079
+ taken = open_left(text, todo, session_id, cwd)
6080
+ if taken:
6081
+ return ("open todo", OPEN_TODO % (
6082
+ len(taken), "; ".join(cut(head, 48) for head in taken[:3])))
6060
6083
  stopped = _stopped_slice(session_id, cwd)
6061
6084
  if not stopped:
6062
6085
  return verdict
@@ -6067,15 +6090,21 @@ def _residual_block(text, rows, shape, verdict=(None, None), session_id=None,
6067
6090
 
6068
6091
  def ready_heads(row, session_id, cwd):
6069
6092
  """A `todo` row's ready heads (`note_todo`), less each one an open slice of
6070
- this session is working on: the slice id appears in the head, or the whole
6071
- head in the slice's own task (`task`, without an omp batch's shared
6072
- context), each as a word-bounded match of two characters or more,
6073
- case-insensitive. omp's `todo start` sets every other `in_progress` item
6074
- back to `pending`, so a dispatched item reads as ready in the list while
6075
- its slice runs. ponytail: a record written before `task` existed falls
6076
- back to its brief's last paragraph. Read from `tezgah_task.pending`, off
6077
- under `slices-off`, and fail open to the row's own heads."""
6078
- heads = [str(h) for h in row.get("heads") or () if str(h).strip()]
6093
+ this session is working on (`_not_running`). omp's `todo start` sets every
6094
+ other `in_progress` item back to `pending`, so a dispatched item reads as
6095
+ ready in the list while its slice runs."""
6096
+ return _not_running([str(h) for h in row.get("heads") or () if str(h).strip()],
6097
+ session_id, cwd)
6098
+
6099
+
6100
+ def _not_running(heads, session_id, cwd):
6101
+ """`heads` less each one an open slice of this session is working on: the
6102
+ slice id appears in the head, or the whole head in the slice's own task
6103
+ (`task`, without an omp batch's shared context), each as a word-bounded
6104
+ match of two characters or more, case-insensitive. ponytail: a record
6105
+ written before `task` existed falls back to its brief's last paragraph.
6106
+ Read from `tezgah_task.pending`, off under `slices-off`, and fail open to
6107
+ `heads`."""
6079
6108
  if not (heads and session_id and cwd) or off("slices-off"):
6080
6109
  return heads
6081
6110
  try:
@@ -6104,17 +6133,15 @@ def newest_todo(rows):
6104
6133
  return next((r for r in rows if r.get("kind") == "todo" and not r.get("agent")), None)
6105
6134
 
6106
6135
 
6107
- def ready_left(text, rows, session_id, cwd=None):
6108
- """The heads of the ready steps the reply leaves: the newest `todo` row's
6109
- ready items (`ready_heads`), less each one a line of the reply names (its
6110
- first 24 characters, inline code kept) beside a KARAR, YETKİ, GERİ ALINAMAZ
6111
- or ORTAM token outside code. Asked like `residual work`: in a turn that
6112
- did work, of a ledger with a turn marker, off under `orchestrate-off`. The
6113
- row may come from an earlier turn: an unfinished step carries over. It is
6114
- found by its serialized shape and only the lines back to the newest
6115
- parent `todo` row are parsed, so the Stop path still parses the turn and
6116
- not the session (StopReadBound). A done item is gone from the list, so it
6117
- never needs the reply."""
6136
+ def _turn_todo(rows, session_id):
6137
+ """The newest parent `todo` row the Stop path judges, else a falsy value
6138
+ (`[]` on the gated exits, the neuter matrix's anchors). Asked like
6139
+ `residual work`: in a turn that did work, of a ledger with a turn marker,
6140
+ off under `orchestrate-off`. The row may come from an earlier turn: an
6141
+ unfinished step carries over. It is found by its serialized shape and only
6142
+ the lines back to the newest parent `todo` row are parsed, so the Stop path
6143
+ still parses the turn and not the session (StopReadBound). Read once for
6144
+ both todo classes."""
6118
6145
  if not session_id or off("orchestrate-off"):
6119
6146
  return []
6120
6147
  if not any(str(r.get("kind")) in STEP_KINDS for r in rows):
@@ -6122,14 +6149,54 @@ def ready_left(text, rows, session_id, cwd=None):
6122
6149
  lines = _ledger_lines(_path(session_id))
6123
6150
  if not _turn_count(lines):
6124
6151
  return []
6125
- row = newest_todo(r for line in reversed(lines) if TODO_ROW.search(line)
6126
- for r in _parse([line]))
6127
- if not row or not row.get("ready"):
6128
- return []
6152
+ return newest_todo(r for line in reversed(lines) if TODO_ROW.search(line)
6153
+ for r in _parse([line]))
6154
+
6155
+
6156
+ def _untyped(text, heads):
6157
+ """`heads` less each one a line of the reply names (its first 24
6158
+ characters, inline code kept) beside a KARAR, YETKİ, GERİ ALINAMAZ or ORTAM
6159
+ token outside code."""
6129
6160
  typed = [_tr_lower(" ".join(line.split())) for line in str(text or "").splitlines()
6130
6161
  if BLOCKER_SAID.search(_prose(line)) or BLOCKER_TRIED.search(_prose(line))]
6131
- return [h for h in ready_heads(row, session_id, cwd)
6132
- if not any(_tr_lower(h)[:24] in line for line in typed)]
6162
+ return [h for h in heads if not any(_tr_lower(h)[:24] in line for line in typed)]
6163
+
6164
+
6165
+ def _ready_in(text, row, session_id, cwd=None):
6166
+ """`ready_left` over a row already read (`_turn_todo`)."""
6167
+ if not row or not row.get("ready"):
6168
+ return []
6169
+ return _untyped(text, ready_heads(row, session_id, cwd))
6170
+
6171
+
6172
+ def ready_left(text, rows, session_id, cwd=None):
6173
+ """The heads of the ready steps the reply leaves: the newest `todo` row's
6174
+ (`_turn_todo`) ready items (`ready_heads`), less each one the reply types
6175
+ with its blocker (`_untyped`). A done item is gone from the list, so it
6176
+ never needs the reply."""
6177
+ return _ready_in(text, _turn_todo(rows, session_id), session_id, cwd)
6178
+
6179
+
6180
+ def open_left(text, row, session_id=None, cwd=None):
6181
+ """The heads of the in-progress items a done claim leaves (the `open todo`
6182
+ class): the row's `open - blocked - ready` count, named by its `taken`
6183
+ heads, when the reply claims done (`asserted_claims`). A blocked item is
6184
+ not counted, an in-progress one a running slice of this session works on
6185
+ (`_not_running`: Claude's TodoWrite has no blocked status, so a dispatched
6186
+ step reads in_progress) is not either, and one the reply types with its
6187
+ blocker (`_untyped`) passes. A `partial` row (Cursor `merge: true`) holds
6188
+ only the changed items, so it is not judged. ponytail: a row written before
6189
+ `taken` existed is named by its count alone."""
6190
+ if not row or row.get("partial"):
6191
+ return []
6192
+ left = (int(row.get("open") or 0) - int(row.get("blocked") or 0)
6193
+ - int(row.get("ready") or 0))
6194
+ if left <= 0 or not asserted_claims(text)[0]:
6195
+ return []
6196
+ heads = [str(h) for h in row.get("taken") or () if str(h).strip()]
6197
+ if not heads:
6198
+ return ["%d in-progress item(s)" % left]
6199
+ return _untyped(text, _not_running(heads, session_id, cwd))
6133
6200
 
6134
6201
 
6135
6202
  def _settle_slices(cwd):
@@ -1185,40 +1185,84 @@ def _in_order(routes, kids):
1185
1185
  return [stem for _ts, stem in kids]
1186
1186
 
1187
1187
 
1188
- def _join(routes, kids, spawned_as):
1189
- """[child stem or None] per route. A child's type is what its parent's task
1190
- call recorded for its name (`spawn` rows, omp), else its own `agent` (the
1191
- type itself on opencode). With every child typed, each route agent's routes
1192
- join that agent's children by `_in_order`, and a child of an unrouted type
1193
- is left out; with any child untyped, order over all of them is the only
1194
- evidence. Whatever is not proven stays None (`unjoined`)."""
1195
- typed = [(ts, stem, spawned_as.get(agent, agent)) for ts, stem, agent in kids]
1196
- if any(kind is None for _ts, _stem, kind in typed):
1197
- stems = _in_order(routes, [(ts, stem) for ts, stem, _k in typed])
1198
- return stems or [None] * len(routes)
1199
- out = [None] * len(routes)
1188
+ # How far apart, in seconds, two children of one task call start: an omp call
1189
+ # of 11 workers opened their ledgers over 2 seconds (ledger 01a12277). A second
1190
+ # task call within that gap would read as part of the first - ponytail: a model
1191
+ # turn between two calls takes longer than this.
1192
+ FAN_OUT_GAP = 2
1193
+
1194
+ # How long after its route a route's first worker may start: tezgah-route runs
1195
+ # right before the task call. The joined omp routes (ledgers 01a12277, 01a12bf1)
1196
+ # started theirs 39 to 1296 s later; 1296 s is a route made beside another and
1197
+ # run after it finished. Past this a same-type child proves nothing.
1198
+ ROUTE_GAP = 1800
1199
+
1200
+
1201
+ def _fan_out(kids):
1202
+ """The children of the first task call in sorted [(ts, key)]: each started
1203
+ within FAN_OUT_GAP of the one before."""
1204
+ out = kids[:1]
1205
+ for kid in kids[1:]:
1206
+ if kid[0] - out[-1][0] > FAN_OUT_GAP:
1207
+ break
1208
+ out.append(kid)
1209
+ return out
1210
+
1211
+
1212
+ def _join(routes, kids):
1213
+ """[[child key]] per route, [] where unjoined; `kids` is [(ts, key, type)],
1214
+ type None when no row typed the child.
1215
+
1216
+ With no child typed, order over all of them is the only evidence
1217
+ (`_in_order`). Otherwise each route agent's routes join that agent's
1218
+ children: a route owns the first fan-out of its type after it and before
1219
+ the next route of its type - later same-type spawns are ones no route sent.
1220
+ It owns nothing when the route before it of its type had no worker yet
1221
+ (both routes' workers may be in this one fan-out), when the fan-out started
1222
+ more than ROUTE_GAP after it, or when an untyped child started inside its
1223
+ span (that child could be its worker). A child of an
1224
+ unrouted type is left out. Whatever is not proven stays unjoined."""
1225
+ if kids and all(kind is None for _ts, _key, kind in kids):
1226
+ keys = _in_order(routes, [(ts, key) for ts, key, _k in kids])
1227
+ return [[key] for key in keys] if keys else [[] for _r in routes]
1228
+ out = [[] for _r in routes]
1229
+ untyped = [ts for ts, _key, kind in kids if kind is None]
1200
1230
  for agent in {fields.get("agent") for _ts, fields in routes}:
1201
1231
  mine = [i for i, (_ts, fields) in enumerate(routes) if fields.get("agent") == agent]
1202
- stems = _in_order([routes[i] for i in mine],
1203
- [(ts, stem) for ts, stem, kind in typed if kind == agent])
1204
- for i, stem in zip(mine, stems or []):
1205
- out[i] = stem
1232
+ theirs = sorted((ts, key) for ts, key, kind in kids if kind == agent)
1233
+ for n, i in enumerate(mine):
1234
+ start = routes[i][0]
1235
+ end = routes[mine[n + 1]][0] if n + 1 < len(mine) else float("inf")
1236
+ if n and not any(routes[mine[n - 1]][0] <= ts < start for ts, _key in theirs):
1237
+ continue
1238
+ batch = _fan_out([kid for kid in theirs if start <= kid[0] < end])
1239
+ if batch and batch[0][0] - start <= ROUTE_GAP \
1240
+ and not any(start <= ts <= batch[-1][0] for ts in untyped):
1241
+ out[i] = [key for _ts, key in batch]
1206
1242
  return out
1207
1243
 
1208
1244
 
1245
+ # A fan-out's outcome is its most telling first check: one failed worker fails
1246
+ # the route, so a fan-out never hides a failure behind a sibling's pass.
1247
+ OUTCOME_ORDER = ("fail", "pass", "ran", "none")
1248
+
1249
+
1209
1250
  def route_report(ledgers):
1210
1251
  """({(via, tier): counts}, counts for Jev routes below the static tier) over
1211
1252
  {ledger stem: rows}.
1212
1253
 
1213
- A routed worker runs in its own session, so its checks are in its own ledger,
1214
- whose `spawned` row names the parent session the route row was written under.
1215
- A route joins the child `_join` can prove is its worker, and the outcome is
1216
- that child's first check (`pass`, `fail`, `ran`, `none`). A route it cannot
1217
- prove - an unrouted spawn, a tie, a host that writes no `spawned` row - is
1218
- `unjoined`: the parent's own next check is never taken for the worker's, and
1219
- neither is a guess. The second count is the V2-as-default question: Jev
1220
- picking under the static table's tier, and how often that first attempt
1221
- failed (docs/models.md)."""
1254
+ A routed worker on omp and opencode runs in its own session, so its checks
1255
+ are in its own ledger, whose `spawned` row names the parent session the
1256
+ route row was written under. On Claude and Codex it runs under its parent's
1257
+ session id: its rows are in the parent's ledger with `agent` = its id, and
1258
+ the `subagent_start` row (SubagentStart's agent_id, agent_type in `detail`)
1259
+ types it. A route joins the children `_join` can prove are its workers, and
1260
+ the outcome is their first check (`pass`, `fail`, `ran`, `none`; over a
1261
+ fan-out, OUTCOME_ORDER's first). A route it cannot prove - an unrouted
1262
+ spawn, a tie, a host that writes no child row - is `unjoined`: the parent's
1263
+ own next check is never taken for the worker's, and neither is a guess. The
1264
+ second count is the V2-as-default question: Jev picking under the static
1265
+ table's tier, and how often that first attempt failed (docs/models.md)."""
1222
1266
  def counts():
1223
1267
  return {"routes": 0, "pass": 0, "fail": 0, "ran": 0, "none": 0, "unjoined": 0}
1224
1268
  def spawn_rows(rows):
@@ -1228,7 +1272,11 @@ def route_report(ledgers):
1228
1272
  for stem, rows in ledgers.items():
1229
1273
  link = next((r for r in rows if r.get("kind") == "spawned" and r.get("parent")),
1230
1274
  None)
1231
- if link:
1275
+ # Cursor's row is written from the parent's subagentStart under the
1276
+ # child's `subagent_id`; that the child's own calls carry this id is not
1277
+ # documented, so the link counts only once a row of the child's landed
1278
+ if link and not (link.get("host") == "cursor"
1279
+ and all(r.get("kind") == "spawned" for r in rows)):
1232
1280
  children.setdefault(ti._slug(link["parent"]), []).append(
1233
1281
  (link.get("ts") or 0, stem, link.get("agent")))
1234
1282
  groups, below = {}, counts()
@@ -1240,15 +1288,27 @@ def route_report(ledgers):
1240
1288
  # orchestrator shares the parent's TEZGAH_SESSION, so its worker names
1241
1289
  # that parent while the row typing the worker is in the orchestrator's
1242
1290
  # ledger. Another session's task names never type this one's children.
1243
- # A name two calls typed differently is untyped (None): order decides.
1291
+ # A name two calls typed differently is untyped (None).
1244
1292
  spawned_as = {}
1245
1293
  for r in spawn_rows(rows) + [r for _ts, kid, _a in children.get(stem, [])
1246
1294
  for r in spawn_rows(ledgers[kid])]:
1247
1295
  if spawned_as.setdefault(r["child"], r["agent"]) != r["agent"]:
1248
1296
  spawned_as[r["child"]] = None
1249
- for (_ts, fields), kid in zip(routes, _join(routes, children.get(stem, []),
1250
- spawned_as)):
1251
- outcome = _outcome(ledgers[kid]) if kid else "unjoined"
1297
+ # a child key is (ledger stem, agent id): "" for a whole ledger; an
1298
+ # empty type (a Cursor subagentStart with no subagent_type) is no type
1299
+ kids = [(ts, (kid, ""), spawned_as.get(name, name) or None)
1300
+ for ts, kid, name in children.get(stem, [])]
1301
+ # a Claude plugin agent's type is plugin-scoped (`tezgah:tezgah-cheap`)
1302
+ kids += [(r.get("ts") or 0, (stem, r["agent"]),
1303
+ str(r.get("detail") or "").rsplit(":", 1)[-1] or None)
1304
+ for r in rows if r.get("kind") == "subagent_start"
1305
+ and isinstance(r.get("agent"), str) and r["agent"]]
1306
+ for (_ts, fields), keys in zip(routes, _join(routes, kids)):
1307
+ got = {_outcome(ledgers[kid] if not agent else
1308
+ [r for r in ledgers[kid] if r.get("agent") == agent
1309
+ and r.get("kind") != "subagent_start"])
1310
+ for kid, agent in keys}
1311
+ outcome = next((o for o in OUTCOME_ORDER if o in got), "unjoined")
1252
1312
  bins = [groups.setdefault((fields["via"], fields["tier"]), counts())]
1253
1313
  static = fields.get("static")
1254
1314
  if fields["via"] == "jev" and static in TIERS and \
@@ -255,14 +255,15 @@ def root_for(path):
255
255
 
256
256
 
257
257
  def _toplevel(real):
258
- """The nearest directory at or above `real` holding a `.git` entry, else None."""
258
+ """The nearest directory at or above `real` holding a `.git` entry, else
259
+ None - the project for a path inside its `.tezgah` (`project_top`)."""
259
260
  cur = real
260
261
  while not os.path.lexists(os.path.join(cur, ".git")):
261
262
  parent = os.path.dirname(cur)
262
263
  if parent == cur:
263
264
  return None
264
265
  cur = parent
265
- return cur
266
+ return project_top(cur)
266
267
 
267
268
 
268
269
  def linked_main(path):
@@ -990,6 +991,26 @@ def workspace(repo):
990
991
  return os.path.join(repo, WORKSPACE)
991
992
 
992
993
 
994
+ def project_top(path):
995
+ """`path`, or the project owning the workspace when the nearest `.git` at or
996
+ above `path` is a `.tezgah`'s own repository.
997
+
998
+ git and the `.git` walk both answer the workspace for a path inside it, and a
999
+ caller that joins `.tezgah` to that answer or sets the project up there wrote
1000
+ `<x>/.tezgah/.tezgah/plans/README.md`, `.tezgah/.cursor` and `.tezgah/.codegraph`.
1001
+ A repository cloned inside the workspace answers for itself. Every resolver
1002
+ (`_toplevel`, the hooks' and CLIs' `repo_root`) passes its answer through here."""
1003
+ cur = path
1004
+ while not os.path.lexists(os.path.join(cur, ".git")):
1005
+ parent = os.path.dirname(cur)
1006
+ if parent == cur:
1007
+ return path
1008
+ cur = parent
1009
+ if os.path.basename(cur) == WORKSPACE:
1010
+ return project_top(os.path.dirname(cur))
1011
+ return path
1012
+
1013
+
993
1014
  # The workspace paths whose text is injected as a standing constraint: each is
994
1015
  # checked for a symlink, because a clone can carry one pointing into its own tree.
995
1016
  WORKSPACE_INJECTED = (".tezgah", os.path.join(".tezgah", "lessons.md"),
@@ -1345,15 +1366,32 @@ def seed_identity(repo, ws=None):
1345
1366
  pass
1346
1367
 
1347
1368
 
1369
+ # What git says when another git process holds the private repository's index
1370
+ # or a ref: every worktree commits into the main checkout's one `.tezgah/.git`,
1371
+ # so two closes or a close and a research commit collide (plan 097: 10 of 19
1372
+ # concurrent rounds).
1373
+ _GIT_LOCKED = (".lock': File exists", "cannot lock ref")
1374
+ # The waits between tries, ~1.5 s in all: a commit there takes milliseconds,
1375
+ # and a lock still held after it is a crashed git, which the caller reports.
1376
+ _LOCK_WAITS = (0.1, 0.2, 0.4, 0.8)
1377
+
1378
+
1348
1379
  def ws_git(repo, *args, **kw):
1349
1380
  """`git -C <repo>/.tezgah <args>` with tezgah's commit identity, as a
1350
- CompletedProcess (text, captured unless `kw` says otherwise). Raises OSError
1351
- when git is not installed, like subprocess.run."""
1381
+ CompletedProcess (text, captured unless `kw` says otherwise), tried again
1382
+ while another git holds a lock there (`_GIT_LOCKED`). Raises OSError when
1383
+ git is not installed, like subprocess.run."""
1352
1384
  import subprocess # deferred: see ensure_workspace
1385
+ import time
1353
1386
  kw.setdefault("capture_output", True)
1354
1387
  kw.setdefault("text", True)
1355
- return subprocess.run(["git", "-C", workspace(repo)] + list(WS_IDENTITY)
1356
- + list(args), **kw)
1388
+ cmd = ["git", "-C", workspace(repo)] + list(WS_IDENTITY) + list(args)
1389
+ for wait in _LOCK_WAITS + (None,):
1390
+ done = subprocess.run(cmd, **kw)
1391
+ err = done.stderr if isinstance(done.stderr, str) else ""
1392
+ if not done.returncode or wait is None or not any(s in err for s in _GIT_LOCKED):
1393
+ return done
1394
+ time.sleep(wait)
1357
1395
 
1358
1396
 
1359
1397
  def guarded_opener(drop_auth=False):
@@ -397,9 +397,9 @@ Routing: `{ROUTE_BIN} "<brief>"` names the worker - tezgah-cheap, -standard or
397
397
  -frontier, or the specialist whose whole job the brief is (tezgah-tester,
398
398
  -docs, -security, -ui, -researcher, -reviewer) - and the spawn uses that name,
399
399
  never the host's generic task agent (overrides for stored data, credentials,
400
- security first; then a Jev judgement; the static phase table with no key). A worker that answers
401
- `ESCALATE:` is restarted on tezgah-frontier with the original brief, never
402
- continued; the main thread never switches model (the cache is per model). A
400
+ security first; then a Jev judgement; the static phase table with no key). A report missing an output field, or an
401
+ `ESCALATE:`, gets one narrower re-brief of the same role naming that field or judgement; only a second miss is
402
+ restarted on tezgah-frontier with the original brief, never continued; the main thread never switches model (the cache is per model). A
403
403
  code-discovery brief names codegraph's tools (`codegraph callers`, `callees`,
404
404
  `impact`, `node`, `files`, `codegraph_explore`) and says "answer from
405
405
  the graph, grep only for literal text"; never a grep-only explorer (observed).
@@ -3550,10 +3550,13 @@ def _check_review_integrity(base, errors, warnings, strict):
3550
3550
  % (" and ".join(high), len(order), order[0][:80]))
3551
3551
 
3552
3552
 
3553
- def check_line(repo, slug, git=True, strict=False):
3553
+ def check_line(repo, slug, git=True, strict=False, code=None):
3554
3554
  """(errors, warnings) for one research line. `git=False` skips the checks that
3555
3555
  need git history, for callers that must stay cheap (the session context).
3556
- `strict=True` turns every guarantee the checker cannot decide into an error."""
3556
+ `strict=True` turns every guarantee the checker cannot decide into an error.
3557
+ `code` is the checkout the check runs in when it is not `repo`: the line
3558
+ lives in the main checkout's workspace (plan 097) and its citations and
3559
+ proofs resolve against the checkout whose branch holds the code."""
3557
3560
  base = line_dir(repo, slug)
3558
3561
  errors, warnings = [], []
3559
3562
  if not os.path.isdir(base):
@@ -3566,7 +3569,8 @@ def check_line(repo, slug, git=True, strict=False):
3566
3569
  errors.append("%s is missing" % name)
3567
3570
  _check_state(base, errors, warnings, strict)
3568
3571
  _check_findings(base, errors, warnings, strict)
3569
- _check_claims(base, errors, warnings, roots=(repo,), strict=strict)
3572
+ _check_claims(base, errors, warnings, roots=(repo, code) if code and code != repo
3573
+ else (repo,), strict=strict)
3570
3574
  _check_predictions(repo, base, errors, warnings, strict, git)
3571
3575
  seal_errors, intact = _check_seal(base)
3572
3576
  errors.extend(seal_errors)
@@ -3685,7 +3689,7 @@ def _check_derived_phase(base, phase, direction, warnings):
3685
3689
  "stopped describing the line" % (direction, derived, want))
3686
3690
 
3687
3691
 
3688
- def check(repo, slug=None, git=True, strict=False, all_lines=True):
3692
+ def check(repo, slug=None, git=True, strict=False, all_lines=True, code=None):
3689
3693
  """{slug: {"errors": [...], "warnings": [...]}} for one line or every line.
3690
3694
 
3691
3695
  With `all_lines=False` and no slug, a line under `done/` is read by its order
@@ -3699,7 +3703,7 @@ def check(repo, slug=None, git=True, strict=False, all_lines=True):
3699
3703
  if errors:
3700
3704
  out[name] = {"errors": errors, "warnings": []}
3701
3705
  continue
3702
- errors, warnings = check_line(repo, name, git=git, strict=strict)
3706
+ errors, warnings = check_line(repo, name, git=git, strict=strict, code=code)
3703
3707
  out[name] = {"errors": errors, "warnings": warnings}
3704
3708
  return out
3705
3709