@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.
- package/CHANGELOG.md +61 -1
- package/MANIFEST +2 -0
- package/bin/tezgah-decisions +6 -14
- package/bin/tezgah-design +21 -5
- package/bin/tezgah-lessons +4 -1
- package/bin/tezgah-research +15 -7
- package/bin/tezgah-route +22 -7
- package/bin/tezgah-task +320 -75
- package/bin/tezgah-taste +8 -5
- package/docs/architecture.md +21 -7
- package/docs/environment.md +3 -1
- package/docs/evidence.md +9 -1
- package/docs/gate.md +24 -7
- package/docs/models.md +40 -12
- package/docs/operations.md +31 -1
- package/docs/research.md +3 -2
- package/docs/testing.md +18 -11
- package/hooks/projects-auto-init.py +29 -1
- package/hooks/tezgah_agents.py +24 -7
- package/hooks/tezgah_context.py +67 -35
- package/hooks/tezgah_gate.py +430 -64
- package/hooks/tezgah_integrity.py +108 -41
- package/hooks/tezgah_models.py +90 -30
- package/hooks/tezgah_paths.py +44 -6
- package/hooks/tezgah_policy.py +3 -3
- package/hooks/tezgah_research.py +9 -5
- package/hooks/tezgah_task.py +319 -67
- package/hooks/tezgah_taste.py +8 -4
- package/hosts/codex/hook.py +23 -3
- package/hosts/cursor/hook.py +11 -0
- package/hosts/opencode/plugins/tezgah.js +171 -4
- package/package.json +1 -1
- package/skills/plan-add/SKILL.md +23 -9
- package/skills/plan-add/render_table.py +8 -3
- package/skills/tezgah-contract/SKILL.md +3 -3
|
@@ -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)
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
whose own text types a blocker
|
|
3286
|
-
`blocked`) counts as blocked:
|
|
3287
|
-
status. `agent` is the host's
|
|
3288
|
-
and the readers skip it.
|
|
3289
|
-
|
|
3290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6071
|
-
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
|
|
6075
|
-
|
|
6076
|
-
|
|
6077
|
-
|
|
6078
|
-
heads
|
|
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
|
|
6108
|
-
"""The
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
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
|
-
|
|
6126
|
-
|
|
6127
|
-
|
|
6128
|
-
|
|
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
|
|
6132
|
-
|
|
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):
|
package/hooks/tezgah_models.py
CHANGED
|
@@ -1185,40 +1185,84 @@ def _in_order(routes, kids):
|
|
|
1185
1185
|
return [stem for _ts, stem in kids]
|
|
1186
1186
|
|
|
1187
1187
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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
|
|
1214
|
-
whose `spawned` row names the parent session the
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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 \
|
package/hooks/tezgah_paths.py
CHANGED
|
@@ -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
|
|
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)
|
|
1351
|
-
|
|
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
|
-
|
|
1356
|
-
|
|
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):
|
package/hooks/tezgah_policy.py
CHANGED
|
@@ -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
|
|
401
|
-
`ESCALATE
|
|
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).
|
package/hooks/tezgah_research.py
CHANGED
|
@@ -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,)
|
|
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
|
|