@r1z4x/tezgah 1.6.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 +140 -1
- package/MANIFEST +7 -0
- package/README.md +6 -3
- package/bin/tezgah-decisions +6 -14
- package/bin/tezgah-design +21 -5
- package/bin/tezgah-docs +18 -16
- package/bin/tezgah-doctor +152 -0
- package/bin/tezgah-lessons +4 -1
- package/bin/tezgah-research +249 -17
- package/bin/tezgah-route +70 -8
- package/bin/tezgah-setup +26 -13
- package/bin/tezgah-task +877 -60
- package/bin/tezgah-taste +56 -15
- package/docs/architecture.md +28 -11
- package/docs/clarity-baseline.json +2 -2
- package/docs/contract.md +108 -24
- package/docs/environment.md +3 -1
- package/docs/evidence.md +209 -28
- package/docs/feature-audit.md +1 -1
- package/docs/gate.md +78 -23
- package/docs/glossary.md +4 -4
- package/docs/hosts.md +8 -8
- package/docs/index.json +11 -5
- package/docs/judge.md +9 -3
- package/docs/models.md +75 -14
- package/docs/operations.md +133 -3
- package/docs/research.md +349 -15
- package/docs/skills.md +1 -1
- package/docs/status-line.md +5 -5
- package/docs/testing.md +20 -13
- package/docs/vision.md +12 -4
- package/hooks/hooks.json +2 -2
- package/hooks/lang/en.json +323 -0
- package/hooks/lang/tr.json +344 -0
- package/hooks/projects-auto-init.py +29 -1
- package/hooks/projects-posttooluse.py +24 -5
- package/hooks/projects-pretooluse.py +9 -4
- package/hooks/projects-stop.py +28 -16
- package/hooks/tezgah_agents.py +53 -17
- package/hooks/tezgah_clarity.py +7 -3
- package/hooks/tezgah_context.py +489 -438
- package/hooks/tezgah_gate.py +996 -80
- package/hooks/tezgah_integrity.py +1547 -197
- package/hooks/tezgah_models.py +427 -45
- package/hooks/tezgah_paths.py +147 -16
- package/hooks/tezgah_policy.py +261 -129
- package/hooks/tezgah_rank.py +29 -27
- package/hooks/tezgah_replay.py +200 -30
- package/hooks/tezgah_research.py +1303 -14
- package/hooks/tezgah_stopspec.py +7 -0
- package/hooks/tezgah_store.py +74 -27
- package/hooks/tezgah_task.py +1201 -40
- package/hooks/tezgah_taste.py +8 -4
- package/hosts/codex/hook.py +32 -13
- package/hosts/cursor/hook.py +32 -14
- package/hosts/dsh/hooks.json +2 -2
- package/hosts/omp/hook.py +44 -23
- package/hosts/omp/tezgah-hook.ts.in +93 -7
- package/hosts/opencode/plugins/tezgah.js +273 -6
- package/output-styles/tezgah.md +76 -42
- package/package.json +1 -1
- package/skills/i-have-adhd/EVALS.md +22 -2
- package/skills/i-have-adhd/SKILL.md +12 -5
- package/skills/plan-add/SKILL.md +27 -11
- package/skills/plan-add/render_table.py +8 -3
- package/skills/plan-sync/SKILL.md +5 -3
- package/skills/ponytail/EVALS.md +21 -2
- package/skills/ponytail/SKILL.md +4 -4
- package/skills/research/SKILL.md +89 -1
- package/skills/tezgah-contract/EVALS.md +52 -7
- package/skills/tezgah-contract/SKILL.md +154 -59
package/hooks/tezgah_taste.py
CHANGED
|
@@ -27,7 +27,8 @@ import time
|
|
|
27
27
|
|
|
28
28
|
import tezgah_integrity as ti
|
|
29
29
|
import tezgah_paths
|
|
30
|
-
from tezgah_paths import _toplevel, armed, root_for,
|
|
30
|
+
from tezgah_paths import (_linked_main_at, _toplevel, armed, root_for,
|
|
31
|
+
workspace_from_repo)
|
|
31
32
|
|
|
32
33
|
ARM = "taste-on"
|
|
33
34
|
LEARN_EVERY = 3600 # seconds between two background `learn` starts per repo
|
|
@@ -46,7 +47,8 @@ WHOLE_KEYS = ("content", "file_text", "text", "new_source")
|
|
|
46
47
|
|
|
47
48
|
def enabled(cwd):
|
|
48
49
|
"""The git root taste rows for `cwd` go to, or None when capture is off
|
|
49
|
-
there
|
|
50
|
+
there: a linked worktree's rows go to its main checkout, the one `.tezgah`
|
|
51
|
+
of the repository. The marker is read first, so the default costs two stats."""
|
|
50
52
|
try:
|
|
51
53
|
if not cwd or not armed(ARM):
|
|
52
54
|
return None
|
|
@@ -54,6 +56,7 @@ def enabled(cwd):
|
|
|
54
56
|
if not root_for(real):
|
|
55
57
|
return None
|
|
56
58
|
top = _toplevel(real)
|
|
59
|
+
top = top and (_linked_main_at(top) or top)
|
|
57
60
|
if not top or not os.path.isdir(os.path.join(top, ".tezgah")):
|
|
58
61
|
return None
|
|
59
62
|
from tezgah_context import repo_marks # imports this module's callers
|
|
@@ -73,10 +76,11 @@ def _cut(text, limit):
|
|
|
73
76
|
|
|
74
77
|
|
|
75
78
|
def _rel(root, path, cwd):
|
|
76
|
-
"""`path` relative to
|
|
79
|
+
"""`path` relative to the checkout holding `cwd` (a worktree's paths read
|
|
80
|
+
the same as main's), or None when it resolves outside it."""
|
|
77
81
|
apath = os.path.realpath(
|
|
78
82
|
str(path) if os.path.isabs(str(path)) else os.path.join(cwd, str(path)))
|
|
79
|
-
rel = os.path.relpath(apath, root)
|
|
83
|
+
rel = os.path.relpath(apath, _toplevel(os.path.realpath(cwd)) or root)
|
|
80
84
|
return None if rel == ".." or rel.startswith(".." + os.sep) else rel
|
|
81
85
|
|
|
82
86
|
|
package/hosts/codex/hook.py
CHANGED
|
@@ -29,9 +29,9 @@ try:
|
|
|
29
29
|
from tezgah_context import ( # noqa: E402
|
|
30
30
|
TOOL_USE_MEASURES, command_text, context_for, health_lines, record,
|
|
31
31
|
shell_kind)
|
|
32
|
-
from tezgah_gate import decision, graph_advice # noqa: E402
|
|
32
|
+
from tezgah_gate import cross_project_note, decision, graph_advice # noqa: E402
|
|
33
33
|
from tezgah_integrity import ( # noqa: E402
|
|
34
|
-
SUBAGENT_CHANNEL, bind_session, changed_files_notice, note_tool, ran_nothing,
|
|
34
|
+
SUBAGENT_CHANNEL, bind_session, changed_files_notice, note, note_tool, ran_nothing,
|
|
35
35
|
report_bytes, stop_reason, untrusted_source)
|
|
36
36
|
from tezgah_paths import HOST_DIRS, off, root_for # noqa: E402
|
|
37
37
|
from tezgah_taste import write_note # noqa: E402
|
|
@@ -39,10 +39,6 @@ try:
|
|
|
39
39
|
except Exception as exc:
|
|
40
40
|
import_failed(exc)
|
|
41
41
|
|
|
42
|
-
# How many times one stop chain may be refused (hooks/projects-stop.py names the
|
|
43
|
-
# same constant): one, deliberately; raising it is owner decision 11.
|
|
44
|
-
STOP_REASKS = 1
|
|
45
|
-
|
|
46
42
|
EVENTS = {
|
|
47
43
|
"SessionStart": "session_start",
|
|
48
44
|
"UserPromptSubmit": "user_prompt",
|
|
@@ -112,7 +108,16 @@ def gate_reason(payload, cwd, session_id):
|
|
|
112
108
|
inp = payload.get("tool_input")
|
|
113
109
|
if not isinstance(inp, dict):
|
|
114
110
|
inp = {}
|
|
115
|
-
|
|
111
|
+
# a subagent's calls carry its `agent_id` (the child thread id; optional in
|
|
112
|
+
# pre-tool-use.command.input.schema.json): its repeat ceilings are its own
|
|
113
|
+
return decision(gate_name(name), inp, cwd, session_id, agent=subagent_id(payload))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def subagent_id(payload):
|
|
117
|
+
"""The child thread id Codex puts on a subagent's hook input, else None (a
|
|
118
|
+
root-agent call; codex-rs/hooks: "Add subagent identity to hook inputs")."""
|
|
119
|
+
aid = payload.get("agent_id")
|
|
120
|
+
return aid if isinstance(aid, str) and aid else None
|
|
116
121
|
|
|
117
122
|
|
|
118
123
|
def verify_outcome(payload):
|
|
@@ -200,7 +205,11 @@ def main():
|
|
|
200
205
|
out_bytes=(report_bytes(result)
|
|
201
206
|
if untrusted_source(tool, inp) == SUBAGENT_CHANNEL
|
|
202
207
|
else result_size(result)),
|
|
203
|
-
empty_run=ran_nothing(result)
|
|
208
|
+
empty_run=ran_nothing(result),
|
|
209
|
+
# the child's rows land in the root session's ledger (Codex's
|
|
210
|
+
# session_id is the root thread's); the route report reads them
|
|
211
|
+
# back by this id
|
|
212
|
+
agent=subagent_id(payload))
|
|
204
213
|
# The gate's graph line for a code-symbol search rides the same
|
|
205
214
|
# non-blocking channel (tezgah_gate.graph_advice): a Codex PreToolUse
|
|
206
215
|
# carries a deny and nothing else.
|
|
@@ -210,7 +219,10 @@ def main():
|
|
|
210
219
|
# (hooks/tezgah_taste.write_note), on the same channel
|
|
211
220
|
taste = (safe(session_id, write_note, session_id, inp, cwd, tool)
|
|
212
221
|
if root_for(cwd) and not verify_outcome(payload) else None)
|
|
213
|
-
|
|
222
|
+
# the cross-project write notice (tezgah_gate.cross_project_note)
|
|
223
|
+
cross = (safe(session_id, cross_project_note, session_id, tool, inp, cwd)
|
|
224
|
+
if root_for(cwd) and not verify_outcome(payload) else None)
|
|
225
|
+
notice = "\n".join(p for p in (notice, advice, taste, cross) if p)
|
|
214
226
|
if notice:
|
|
215
227
|
# Codex's PostToolUse output carries `additionalContext` with the
|
|
216
228
|
# result - the field is part of its own hook output schema
|
|
@@ -221,6 +233,13 @@ def main():
|
|
|
221
233
|
return
|
|
222
234
|
if event == "SubagentStart":
|
|
223
235
|
safe(session_id, record, session_id, "orch")
|
|
236
|
+
# the child's typing row in the root ledger: SubagentStart requires
|
|
237
|
+
# agent_id and agent_type (codex-rs/hooks/schema/generated/
|
|
238
|
+
# subagent-start.command.input.schema.json)
|
|
239
|
+
kind = payload.get("agent_type")
|
|
240
|
+
if subagent_id(payload) and isinstance(kind, str) and root_for(cwd):
|
|
241
|
+
safe(session_id, note, session_id, "subagent_start", kind,
|
|
242
|
+
agent=subagent_id(payload))
|
|
224
243
|
if event == "Stop":
|
|
225
244
|
out = {}
|
|
226
245
|
# Codex's stop.input carries the same fields Claude's does
|
|
@@ -228,13 +247,13 @@ def main():
|
|
|
228
247
|
# {"decision": "block", "reason": ...}, so the integrity rule's second
|
|
229
248
|
# half runs here too: a done/tested claim with nothing observed behind it
|
|
230
249
|
# cannot end the turn. `verify-off` drops it; outside a root it is inert.
|
|
231
|
-
# The reply after a block (`stop_hook_active`) is judged
|
|
232
|
-
#
|
|
250
|
+
# The reply after a block (`stop_hook_active`) is judged again and
|
|
251
|
+
# refused at most once more, for a class this turn was not refused for
|
|
252
|
+
# yet; otherwise it is one `after_block` row (tezgah_integrity._record_only).
|
|
233
253
|
if not off("verify-off") and root_for(cwd):
|
|
234
|
-
blocked = 1 if payload.get("stop_hook_active") else 0
|
|
235
254
|
reason = safe(session_id, stop_reason,
|
|
236
255
|
payload.get("last_assistant_message"), session_id,
|
|
237
|
-
cwd=cwd,
|
|
256
|
+
cwd=cwd, after_block=bool(payload.get("stop_hook_active")))
|
|
238
257
|
if reason:
|
|
239
258
|
out["decision"] = "block"
|
|
240
259
|
out["reason"] = reason
|
package/hosts/cursor/hook.py
CHANGED
|
@@ -48,10 +48,10 @@ from tezgah_guard import attest_session, import_failed, safe # noqa: E402
|
|
|
48
48
|
try:
|
|
49
49
|
from tezgah_context import ( # noqa: E402
|
|
50
50
|
command_text, context_for, record, shell_kind, slug, under)
|
|
51
|
-
from tezgah_gate import decision, explored, graph_advice # noqa: E402
|
|
51
|
+
from tezgah_gate import cross_project_note, decision, explored, graph_advice # noqa: E402
|
|
52
52
|
from tezgah_integrity import ( # noqa: E402
|
|
53
53
|
SUBAGENT_CHANNEL, bind_session, note, note_tool, ran_nothing, report_bytes,
|
|
54
|
-
stop_reason)
|
|
54
|
+
residual_text, stop_reason)
|
|
55
55
|
from tezgah_paths import off # noqa: E402
|
|
56
56
|
import tezgah_store # noqa: E402
|
|
57
57
|
from tezgah_taste import write_note # noqa: E402
|
|
@@ -59,12 +59,10 @@ try:
|
|
|
59
59
|
except Exception as exc:
|
|
60
60
|
import_failed(exc)
|
|
61
61
|
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
# flag is still read for a build that sends it.
|
|
67
|
-
STOP_REASKS = 1
|
|
62
|
+
# Cursor's `stop` payload carries `loop_count` (the follow-ups already sent in
|
|
63
|
+
# this chain) and no `stop_hook_active` (cursor-agent 2026.10.01, captured
|
|
64
|
+
# 2026-10-07); the flag is still read for a build that sends it. The count goes
|
|
65
|
+
# to the core, which owns the re-ask budget (tezgah_integrity._record_only).
|
|
68
66
|
|
|
69
67
|
|
|
70
68
|
def asked(payload):
|
|
@@ -251,14 +249,17 @@ def remember_answer(session_id, text):
|
|
|
251
249
|
one is stored as that many characters of its head, `ANSWER_CUT` with the
|
|
252
250
|
count of what was dropped, then `ANSWER_TAIL` characters of its tail - so
|
|
253
251
|
both the opener the Stop rule reads and the closing marker it must not lose
|
|
254
|
-
survive the round trip. A
|
|
255
|
-
|
|
252
|
+
survive the round trip. A residual section the cut would drop (plan 080:
|
|
253
|
+
"Açık kalanlar" in the middle of a long reply) is kept, label and items,
|
|
254
|
+
between the cut marker and the tail. A reply with no `conversation_id` is
|
|
255
|
+
not stored; a store that cannot be written is not fatal."""
|
|
256
256
|
if not session_id or not text:
|
|
257
257
|
return
|
|
258
258
|
text = str(text)
|
|
259
259
|
dropped = len(text) - ANSWER_HEAD - ANSWER_TAIL
|
|
260
260
|
if dropped > 0:
|
|
261
|
-
text = text[:ANSWER_HEAD] + ANSWER_CUT % dropped
|
|
261
|
+
text = (text[:ANSWER_HEAD] + ANSWER_CUT % dropped
|
|
262
|
+
+ residual_text(text[ANSWER_HEAD:-ANSWER_TAIL]) + text[-ANSWER_TAIL:])
|
|
262
263
|
tezgah_store.put_doc("cursor_answer", answer_key(session_id), text)
|
|
263
264
|
|
|
264
265
|
|
|
@@ -316,7 +317,11 @@ def dispatch(payload):
|
|
|
316
317
|
# (hooks/tezgah_taste.write_note)
|
|
317
318
|
taste = (write_note(session_id, inp, cwd, gate_name(payload.get("tool_name", "")))
|
|
318
319
|
if under(cwd) and not call_failed(payload) else None)
|
|
319
|
-
|
|
320
|
+
# the cross-project write notice (tezgah_gate.cross_project_note)
|
|
321
|
+
cross = (cross_project_note(session_id, gate_name(payload.get("tool_name", "")),
|
|
322
|
+
inp, cwd)
|
|
323
|
+
if under(cwd) and not call_failed(payload) else None)
|
|
324
|
+
text = "\n".join(t for t in (notice, reinforce, advice, taste, cross) if t)
|
|
320
325
|
out = {"additional_context": text} if text else {}
|
|
321
326
|
elif event == "postToolUseFailure":
|
|
322
327
|
if kind:
|
|
@@ -383,6 +388,17 @@ def dispatch(payload):
|
|
|
383
388
|
else:
|
|
384
389
|
record(session_id, "orch")
|
|
385
390
|
out = {"permission": "allow"}
|
|
391
|
+
# The child's ledger opens with its parent, as on omp and opencode:
|
|
392
|
+
# a Cursor subagent runs under its own conversation id (observed with
|
|
393
|
+
# cursor-agent 2026.10.01), and this event names it `subagent_id`
|
|
394
|
+
# beside `parent_conversation_id` (Cursor hooks docs, subagentStart).
|
|
395
|
+
# `host` marks the row whose id the report has not seen carried by
|
|
396
|
+
# the child's own calls (tezgah_models.route_report).
|
|
397
|
+
child, parent = payload.get("subagent_id"), payload.get("parent_conversation_id")
|
|
398
|
+
if isinstance(child, str) and child and isinstance(parent, str) and parent \
|
|
399
|
+
and under(cwd):
|
|
400
|
+
safe(child, note, child, "spawned", "", parent=parent,
|
|
401
|
+
agent=str(payload.get("subagent_type") or "") or None, host="cursor")
|
|
386
402
|
# The delegate is a fresh context that must know the rules exist, so
|
|
387
403
|
# it gets the same short brief every other host hands a subagent.
|
|
388
404
|
# Cursor documents no context field on subagentStart (its output is
|
|
@@ -421,10 +437,12 @@ def dispatch(payload):
|
|
|
421
437
|
# Only a finished turn is nudged - an aborted or errored one has no claim
|
|
422
438
|
# to check - and `loop_count` is the platform's own cap on follow-ups.
|
|
423
439
|
out = {}
|
|
440
|
+
# The reply a follow-up started is judged again: the core refuses it at
|
|
441
|
+
# most once more, for a class this turn was not refused for yet.
|
|
424
442
|
if (payload.get("status") in (None, "completed")
|
|
425
|
-
and asked(payload) < STOP_REASKS
|
|
426
443
|
and not off("verify-off") and under(cwd)):
|
|
427
|
-
reason = stop_reason(last_answer(session_id), session_id, cwd=cwd
|
|
444
|
+
reason = stop_reason(last_answer(session_id), session_id, cwd=cwd,
|
|
445
|
+
after_block=asked(payload))
|
|
428
446
|
if reason:
|
|
429
447
|
out = {"decision": "block", "reason": reason}
|
|
430
448
|
elif event == "beforeSubmitPrompt":
|
package/hosts/dsh/hooks.json
CHANGED
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
{ "hooks": [ { "type": "command", "command": "\"${TEZGAH_PYTHON:-python3}\" \"${CLAUDE_PLUGIN_ROOT}/hooks/projects-auto-init.py\"", "timeout": 10 } ] }
|
|
11
11
|
],
|
|
12
12
|
"PreToolUse": [
|
|
13
|
-
{ "matcher": "Agent|agent|Task|task|Grep|grep|Bash|bash|PowerShell|pwsh|Shell|shell|subagent|Edit|Write|MultiEdit|NotebookEdit", "hooks": [ { "type": "command", "command": "\"${TEZGAH_PYTHON:-python3}\" \"${CLAUDE_PLUGIN_ROOT}/hooks/projects-pretooluse.py\"", "timeout": 5 } ] },
|
|
13
|
+
{ "matcher": "Agent|agent|Task|task|Grep|grep|Bash|bash|PowerShell|pwsh|Shell|shell|subagent|Edit|Write|MultiEdit|NotebookEdit|AskUserQuestion", "hooks": [ { "type": "command", "command": "\"${TEZGAH_PYTHON:-python3}\" \"${CLAUDE_PLUGIN_ROOT}/hooks/projects-pretooluse.py\"", "timeout": 5 } ] },
|
|
14
14
|
{ "matcher": "^mcp__(?:.*[^A-Za-z0-9])?(?:append|cancel|clear|click|close|comment|commit|copy|create|delete|deploy|drag|edit|email|evaluate|exec|execute|fill|insert|install|kill|launch|merge|message|mkdir|move|navigate|notify|patch|pause|post|press|print|publish|push|put|release|remove|rename|replace|reply|resume|review|run|save|send|set|share|skip|start|stop|submit|swipe|tag|tap|terminate|type|uninstall|update|upload|write)(?:[^A-Za-z0-9].*)?$", "hooks": [ { "type": "command", "command": "\"${TEZGAH_PYTHON:-python3}\" \"${CLAUDE_PLUGIN_ROOT}/hooks/projects-pretooluse.py\"", "timeout": 5 } ] }
|
|
15
15
|
],
|
|
16
16
|
"PostToolUse": [
|
|
17
17
|
{ "matcher": "Edit|edit|Write|write|MultiEdit|NotebookEdit|Bash|bash|PowerShell|pwsh|str_replace_editor|WebFetch|WebSearch|web_fetch|web_search|fetch|mcp__.*", "hooks": [ { "type": "command", "command": "TEZGAH_CALL_OUTCOME=none \"${TEZGAH_PYTHON:-python3}\" \"${CLAUDE_PLUGIN_ROOT}/hooks/projects-posttooluse.py\"", "timeout": 5 } ] },
|
|
18
|
-
{ "matcher": "Agent|Task|Skill", "hooks": [ { "type": "command", "command": "TEZGAH_CALL_OUTCOME=none \"${TEZGAH_PYTHON:-python3}\" \"${CLAUDE_PLUGIN_ROOT}/hooks/projects-posttooluse.py\"", "timeout": 5 } ] }
|
|
18
|
+
{ "matcher": "Agent|Task|Skill|TodoWrite", "hooks": [ { "type": "command", "command": "TEZGAH_CALL_OUTCOME=none \"${TEZGAH_PYTHON:-python3}\" \"${CLAUDE_PLUGIN_ROOT}/hooks/projects-posttooluse.py\"", "timeout": 5 } ] }
|
|
19
19
|
]
|
|
20
20
|
}
|
|
21
21
|
}
|
package/hosts/omp/hook.py
CHANGED
|
@@ -8,7 +8,7 @@ the gate and the evidence ledger underneath are the shared core in `hooks/`,
|
|
|
8
8
|
the same code every other host runs.
|
|
9
9
|
|
|
10
10
|
{"event": "session_start", "cwd": ..., "session_id": ..., "subagent": bool,
|
|
11
|
-
"parent": <the main session's id>, "agent": <the child's AgentId>}
|
|
11
|
+
"parent": <the main session's id>, "agent": <the child's AgentId>, "fork_of": <id>}
|
|
12
12
|
-> {"context": <this repo's live state, or the subagent brief>,
|
|
13
13
|
"status": "pony✓ ...", "idx": glyph}
|
|
14
14
|
{"event": "status", "cwd": ..., "session_id": ...}
|
|
@@ -19,10 +19,11 @@ the same code every other host runs.
|
|
|
19
19
|
{"event": "user_prompt", "cwd": ..., "prompt": ...}
|
|
20
20
|
-> {"context": <reminder + the rules this prompt arms>}
|
|
21
21
|
{"event": "pre_tool_use", "cwd": ..., "tool": ..., "input": {...}}
|
|
22
|
-
-> {"deny": reason} | {"advice": line} (the graph line the
|
|
23
|
-
|
|
22
|
+
-> {"deny": reason} | {"advice": line} (the graph line, the idle-wait
|
|
23
|
+
warning on a `wait`, or the ask-policy warning on an `ask`, the
|
|
24
|
+
bridge hands the model with this call's result; never a block)
|
|
24
25
|
{"event": "post_tool_use", "cwd": ..., "tool": ..., "input": {...},
|
|
25
|
-
"failed": bool, "idx": glyph}
|
|
26
|
+
"failed": bool, "idx": glyph, "todo": [...]}
|
|
26
27
|
-> {"status": "pony✓ ...", "idx": glyph, "label": one line}
|
|
27
28
|
(records the evidence the Stop rule reads; `idx` echoed from the
|
|
28
29
|
payload skips the git probe; `label` is the untrusted-content notice
|
|
@@ -31,8 +32,9 @@ the same code every other host runs.
|
|
|
31
32
|
and is absent for every ordinary result)
|
|
32
33
|
{"event": "stop", "last_assistant_message": ..., "stop_hook_active": bool}
|
|
33
34
|
-> {"decision": "block", "reason": reason}
|
|
34
|
-
(
|
|
35
|
-
|
|
35
|
+
(the reply after a block is judged again and refused at most once
|
|
36
|
+
more, for a class this turn was not refused for yet; otherwise it is
|
|
37
|
+
recorded as an `after_block` row - tezgah_integrity._record_only)
|
|
36
38
|
|
|
37
39
|
Every answer that carries the line carries `idx` with it - the glyph of the
|
|
38
40
|
line's idx mark - so the caller can hand it back on the redraws that must not
|
|
@@ -58,7 +60,9 @@ try:
|
|
|
58
60
|
from tezgah_context import ( # noqa: E402
|
|
59
61
|
color_default, command_text, context_for, health_segments, record,
|
|
60
62
|
render_tiers, shell_kind, skill_read_kind)
|
|
61
|
-
from tezgah_gate import
|
|
63
|
+
from tezgah_gate import ( # noqa: E402
|
|
64
|
+
ask_advice, cross_project_note, decision, graph_advice, ready_advice,
|
|
65
|
+
spawn_advice)
|
|
62
66
|
from tezgah_integrity import ( # noqa: E402
|
|
63
67
|
SUBAGENT_CHANNEL, bind_session, note, note_tool, stop_reason, untrusted_label,
|
|
64
68
|
untrusted_source)
|
|
@@ -68,10 +72,9 @@ try:
|
|
|
68
72
|
except Exception as exc:
|
|
69
73
|
import_failed(exc)
|
|
70
74
|
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
STOP_REASKS = 1
|
|
75
|
+
# omp has no subagent-end event: `session_stop` does not fire for task sessions,
|
|
76
|
+
# so the bridge sends `subagent_stop` from the child's terminal `agent_end`.
|
|
77
|
+
# The re-ask budget is the core's (tezgah_integrity.STOP_REASKS, plan 083).
|
|
75
78
|
|
|
76
79
|
|
|
77
80
|
def classify(tool, inp):
|
|
@@ -148,7 +151,10 @@ def handle(payload):
|
|
|
148
151
|
reason = decision(tool, inp, cwd, session_id)
|
|
149
152
|
if reason:
|
|
150
153
|
return {"deny": reason}
|
|
151
|
-
advice = graph_advice(tool, inp, cwd, "omp")
|
|
154
|
+
advice = (graph_advice(tool, inp, cwd, "omp")
|
|
155
|
+
or ready_advice(tool, session_id, cwd)
|
|
156
|
+
or spawn_advice(tool, inp, session_id, cwd)
|
|
157
|
+
or ask_advice(tool, inp, session_id, cwd))
|
|
152
158
|
return {"advice": advice} if advice else {}
|
|
153
159
|
if not root_for(cwd):
|
|
154
160
|
return {}
|
|
@@ -170,6 +176,11 @@ def handle(payload):
|
|
|
170
176
|
# `tezgah-route --report` joins the two through this row
|
|
171
177
|
note(session_id, "spawned", "", parent=str(payload["parent"]),
|
|
172
178
|
agent=payload.get("agent"))
|
|
179
|
+
elif payload.get("fork_of"):
|
|
180
|
+
# an omp fork names the session it was forked from: the race rule
|
|
181
|
+
# names that relation instead of an opaque ledger id
|
|
182
|
+
# (tezgah_integrity._relation)
|
|
183
|
+
note(session_id, "forked", "", parent=str(payload["fork_of"]))
|
|
173
184
|
context = context_for(kind, cwd, dict(payload, host="omp"),
|
|
174
185
|
with_core=False)
|
|
175
186
|
if context:
|
|
@@ -229,7 +240,8 @@ def handle(payload):
|
|
|
229
240
|
failed=failed if isinstance(failed, bool) else None,
|
|
230
241
|
out_bytes=size if isinstance(size, int) and size >= 0 else None,
|
|
231
242
|
source=source, cwd=cwd,
|
|
232
|
-
empty_run=payload.get("empty_run") is True
|
|
243
|
+
empty_run=payload.get("empty_run") is True,
|
|
244
|
+
todo=payload.get("todo") if isinstance(payload.get("todo"), list) else None)
|
|
233
245
|
if str(tool).lower() == "task" and isinstance(inp.get("tasks"), list):
|
|
234
246
|
# omp names a child's ledger by its task name (`<parent>/<name>.jsonl`)
|
|
235
247
|
# and only this call knows which agent that name runs - `task` when
|
|
@@ -248,25 +260,34 @@ def handle(payload):
|
|
|
248
260
|
# reads where the text came from - or, on an effect after such a read,
|
|
249
261
|
# the taint notice - while it reads the result.
|
|
250
262
|
# the opt-in taste note for the first write to this file type
|
|
251
|
-
# (hooks/tezgah_taste.write_note)
|
|
263
|
+
# (hooks/tezgah_taste.write_note) and the cross-project write notice
|
|
264
|
+
# (hooks/tezgah_gate.py::cross_project_note) ride the same label
|
|
252
265
|
taste = (safe(session_id, write_note, session_id, inp, cwd, tool)
|
|
253
266
|
if failed is not True else None)
|
|
254
|
-
|
|
267
|
+
cross = (safe(session_id, cross_project_note, session_id, tool, inp, cwd)
|
|
268
|
+
if failed is not True else None)
|
|
269
|
+
label = "\n".join(t for t in (label, taste, cross) if t)
|
|
255
270
|
if label:
|
|
256
271
|
out["label"] = label
|
|
257
272
|
return out
|
|
258
273
|
if event == "stop":
|
|
259
274
|
if off("verify-off"):
|
|
260
275
|
return {}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
reason = stop_reason(payload.get("last_assistant_message"), session_id,
|
|
268
|
-
cwd=cwd)
|
|
276
|
+
# the reply after a block is judged again; the core decides from the
|
|
277
|
+
# turn's ledger whether it may be refused once more (plan 083), and a
|
|
278
|
+
# core that raises has refused nothing
|
|
279
|
+
reason = safe(session_id, stop_reason,
|
|
280
|
+
payload.get("last_assistant_message"), session_id,
|
|
281
|
+
cwd=cwd, after_block=bool(payload.get("stop_hook_active")))
|
|
269
282
|
return {"decision": "block", "reason": reason} if reason else {}
|
|
283
|
+
if event == "subagent_stop":
|
|
284
|
+
# record-only (ADR 011): judged on the child's own rows, the row lands
|
|
285
|
+
# in the parent's ledger so the parent's Stop reads its residual count
|
|
286
|
+
if not off("verify-off") and payload.get("parent"):
|
|
287
|
+
safe(session_id, stop_reason, payload.get("last_assistant_message"),
|
|
288
|
+
session_id, cwd=cwd, subagent=True, agent=payload.get("agent"),
|
|
289
|
+
parent=str(payload["parent"]))
|
|
290
|
+
return {}
|
|
270
291
|
return {}
|
|
271
292
|
|
|
272
293
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, openSync, readdirSync, readSync } from "node:fs";
|
|
3
3
|
import { basename, delimiter, dirname, join } from "node:path";
|
|
4
4
|
|
|
5
5
|
// tezgah-setup substitutes the absolute path of hosts/omp/hook.py, the python
|
|
@@ -206,10 +206,12 @@ function draw(ctx: any, line: string,
|
|
|
206
206
|
// the Stop rule reads, or arrive from outside the user and the workspace.
|
|
207
207
|
// read/grep/glob are absent on purpose: a read-heavy turn would otherwise spawn
|
|
208
208
|
// python per call for nothing. The three web tools are here because their result
|
|
209
|
-
// is exactly the one the untrusted-content label exists for.
|
|
209
|
+
// is exactly the one the untrusted-content label exists for. `todo` and `wait`
|
|
210
|
+
// are the orchestrator's queue and its idle call (tezgah_integrity.note_todo).
|
|
210
211
|
const WATCHED = ["bash", "shell", "command", "edit", "write", "multiedit",
|
|
211
212
|
"notebookedit", "apply_patch", "edit_file", "write_file", "search_replace",
|
|
212
|
-
"task", "agent", "spawn_agent", "web_search", "web_fetch", "browser"
|
|
213
|
+
"task", "agent", "spawn_agent", "web_search", "web_fetch", "browser", "todo",
|
|
214
|
+
"wait"];
|
|
213
215
|
|
|
214
216
|
// A read of a tezgah skill file is the one read whose result moves a status
|
|
215
217
|
// mark, and matching it here keeps the rule that made reads unwatched: ordinary
|
|
@@ -266,9 +268,11 @@ function skillRead(tool: string, args: any): boolean {
|
|
|
266
268
|
// checks (bash), the test-skip check (the edit/write names), and the code-graph
|
|
267
269
|
// nudge on omp's search tools (grep, find, search, ast_grep). An MCP tool is
|
|
268
270
|
// consulted when its name carries an effect verb (MCP_EFFECT, see `tool_call`).
|
|
271
|
+
// `wait` gets the idle-wait warning (tezgah_gate.ready_advice) and `ask` the
|
|
272
|
+
// ask-policy warning (tezgah_gate.ask_advice), never a block.
|
|
269
273
|
const GATED = ["grep", "find", "search", "ast_grep", "bash", "shell", "command",
|
|
270
274
|
"task", "agent", "edit", "write", "multiedit", "notebookedit", "apply_patch",
|
|
271
|
-
"edit_file", "write_file", "search_replace", "powershell", "pwsh"];
|
|
275
|
+
"edit_file", "write_file", "search_replace", "powershell", "pwsh", "wait", "ask"];
|
|
272
276
|
|
|
273
277
|
// The gate's non-blocking graph line (tezgah_gate.graph_advice), held from the
|
|
274
278
|
// call to its result: `tool_call` can only block, `tool_result` can hand the
|
|
@@ -311,6 +315,44 @@ function emptyRun(content: any): boolean {
|
|
|
311
315
|
return EMPTY_RUN.test(text.slice(-EMPTY_RUN_TAIL));
|
|
312
316
|
}
|
|
313
317
|
|
|
318
|
+
// The text of the newest assistant message in an agent_end `messages` list:
|
|
319
|
+
// the subagent's final report, which the hook reads like a Stop reply.
|
|
320
|
+
function lastReply(messages: any): string | undefined {
|
|
321
|
+
if (!Array.isArray(messages)) return undefined;
|
|
322
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
323
|
+
const m = messages[i];
|
|
324
|
+
if (m?.role !== "assistant") continue;
|
|
325
|
+
if (typeof m.content === "string") return m.content;
|
|
326
|
+
if (Array.isArray(m.content)) {
|
|
327
|
+
return m.content.map((p: any) => (p?.type === "text" && typeof p.text === "string"
|
|
328
|
+
? p.text : "")).join("\n");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// The id of the session that spawned this one: the `session` header line of the
|
|
335
|
+
// file `parentSession` names, so a grandchild's end reaches the child that
|
|
336
|
+
// spawned it. The main session's exported id stands in when that file cannot be
|
|
337
|
+
// read; only the first SESSION_HEAD bytes are read, never the transcript.
|
|
338
|
+
const SESSION_HEAD = 16384;
|
|
339
|
+
function parentSessionId(ctx: any): string | undefined {
|
|
340
|
+
try {
|
|
341
|
+
const file = String(ctx?.sessionManager?.getHeader?.()?.parentSession ?? "");
|
|
342
|
+
const fd = openSync(file, "r");
|
|
343
|
+
const buf = Buffer.alloc(SESSION_HEAD);
|
|
344
|
+
const n = readSync(fd, buf, 0, SESSION_HEAD, 0);
|
|
345
|
+
closeSync(fd);
|
|
346
|
+
for (const line of buf.subarray(0, n).toString("utf8").split("\n")) {
|
|
347
|
+
try {
|
|
348
|
+
const row = JSON.parse(line);
|
|
349
|
+
if (row?.type === "session" && row.id) return String(row.id);
|
|
350
|
+
} catch { /* a title line cut at the buffer's end, or another row */ }
|
|
351
|
+
}
|
|
352
|
+
} catch { /* no readable parent file: the exported id below */ }
|
|
353
|
+
return process.env.TEZGAH_SESSION || undefined;
|
|
354
|
+
}
|
|
355
|
+
|
|
314
356
|
// The hook's failure is the one state the bridge can see and the hook cannot
|
|
315
357
|
// report: no interpreter to start it with, the checkout moved, the hook crashed
|
|
316
358
|
// or ran past the timeout. It is remembered here so the next event can say so.
|
|
@@ -387,6 +429,15 @@ function sessionOf(ctx: any): string | undefined {
|
|
|
387
429
|
}
|
|
388
430
|
}
|
|
389
431
|
|
|
432
|
+
// The session file is omp's transcript: the router-context line reads it.
|
|
433
|
+
function sessionFileOf(ctx: any): string | undefined {
|
|
434
|
+
try {
|
|
435
|
+
return ctx?.sessionManager?.getSessionFile?.() ?? undefined;
|
|
436
|
+
} catch {
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
390
441
|
// A task subagent runs in its own session with the same extensions, so it gets
|
|
391
442
|
// session_start too. It must get the subagent brief, not the parent's payload:
|
|
392
443
|
// the parent's runs the indexer, regenerates the agents and tells the model to
|
|
@@ -409,6 +460,20 @@ function isSubagent(ctx: any): boolean {
|
|
|
409
460
|
}
|
|
410
461
|
}
|
|
411
462
|
|
|
463
|
+
// The session an omp fork was forked from: a main session whose header still
|
|
464
|
+
// names a `parentSession` (an id, or a `<stamp>_<id>.jsonl` path). The hook
|
|
465
|
+
// writes it as a `forked` row, so the race rule names a fork as one instead of
|
|
466
|
+
// an opaque ledger id. "" for a session that is no fork.
|
|
467
|
+
function forkParent(ctx: any): string {
|
|
468
|
+
try {
|
|
469
|
+
const parent = String(ctx?.sessionManager?.getHeader?.()?.parentSession ?? "");
|
|
470
|
+
const stem = basename(parent.replace(/\\/g, "/"), ".jsonl");
|
|
471
|
+
return stem.slice(stem.lastIndexOf("_") + 1);
|
|
472
|
+
} catch {
|
|
473
|
+
return "";
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
412
477
|
export default function tezgahOmp(pi: any): void {
|
|
413
478
|
// The idx glyph the last probed answer carried. omp redraws the line after
|
|
414
479
|
// every watched tool and the stamp probe behind that mark is the line's only
|
|
@@ -538,6 +603,16 @@ export default function tezgahOmp(pi: any): void {
|
|
|
538
603
|
tool = "";
|
|
539
604
|
stopMotion();
|
|
540
605
|
paint(ctx);
|
|
606
|
+
// omp fires no session_stop for a task subagent, so its end is recorded
|
|
607
|
+
// here: the child's last reply, judged record-only, lands in the parent's
|
|
608
|
+
// ledger with its untyped residual count (ADR 011, S4 finding 2).
|
|
609
|
+
const parent = isSubagent(ctx) ? parentSessionId(ctx) : undefined;
|
|
610
|
+
if (parent && event?.willContinue !== true) {
|
|
611
|
+
ask({ event: "subagent_stop", cwd: cwdOf(ctx), session_id: sessionOf(ctx),
|
|
612
|
+
parent,
|
|
613
|
+
agent: basename(String(ctx?.sessionManager?.getSessionFile?.() ?? ""), ".jsonl"),
|
|
614
|
+
last_assistant_message: lastReply(event?.messages) });
|
|
615
|
+
}
|
|
541
616
|
});
|
|
542
617
|
pi.on("session_shutdown", async () => {
|
|
543
618
|
busy = false;
|
|
@@ -559,8 +634,10 @@ export default function tezgahOmp(pi: any): void {
|
|
|
559
634
|
? { parent: process.env.TEZGAH_SESSION,
|
|
560
635
|
agent: basename(String(ctx?.sessionManager?.getSessionFile?.() ?? ""), ".jsonl") }
|
|
561
636
|
: {};
|
|
637
|
+
const fork = subagent ? "" : forkParent(ctx);
|
|
562
638
|
const res = ask({ event: "session_start", cwd: cwdOf(ctx),
|
|
563
|
-
session_id: session, subagent, ...link
|
|
639
|
+
session_id: session, subagent, ...link,
|
|
640
|
+
...(fork ? { fork_of: fork } : {}) });
|
|
564
641
|
if (hookError) {
|
|
565
642
|
const notice = failureNotice(ctx, session);
|
|
566
643
|
if (notice) {
|
|
@@ -626,8 +703,10 @@ export default function tezgahOmp(pi: any): void {
|
|
|
626
703
|
// again.
|
|
627
704
|
const prompt = String(event?.prompt ?? "").trim();
|
|
628
705
|
if (!prompt) return undefined;
|
|
706
|
+
// `subagent`: a task prompt is the parent's brief, never the user's grant
|
|
629
707
|
const res = ask({ event: "user_prompt", cwd: cwdOf(ctx), prompt,
|
|
630
|
-
session_id: session
|
|
708
|
+
session_id: session, transcript_path: sessionFileOf(ctx),
|
|
709
|
+
subagent: isSubagent(ctx) });
|
|
631
710
|
if (hookError) return noticeMessage(ctx, session);
|
|
632
711
|
if (!res.context) return undefined;
|
|
633
712
|
// A hidden message, not a system-prompt rewrite: omp keeps per-request
|
|
@@ -683,10 +762,17 @@ export default function tezgahOmp(pi: any): void {
|
|
|
683
762
|
const content = event?.content;
|
|
684
763
|
const resultLen = typeof content === "string" ? content.length
|
|
685
764
|
: Array.isArray(content) ? content.length : undefined;
|
|
765
|
+
// omp's todo call holds one op and its result's `details.phases` the whole
|
|
766
|
+
// list, so that list crosses for this one tool: the queue the Stop rule and
|
|
767
|
+
// the idle-wait warning read (tezgah_integrity.note_todo).
|
|
768
|
+
const phases = tool.toLowerCase() === "todo" ? event?.details?.phases : undefined;
|
|
769
|
+
const todo = Array.isArray(phases)
|
|
770
|
+
? phases.flatMap((p: any) => (Array.isArray(p?.tasks) ? p.tasks : []))
|
|
771
|
+
: undefined;
|
|
686
772
|
const res = ask({ event: "post_tool_use", cwd: cwdOf(ctx), tool,
|
|
687
773
|
input: event?.input ?? {}, failed, idx: idxGlyph,
|
|
688
774
|
session_id: sessionOf(ctx), result_len: resultLen,
|
|
689
|
-
empty_run: emptyRun(content) || undefined });
|
|
775
|
+
empty_run: emptyRun(content) || undefined, todo });
|
|
690
776
|
if (hookError) return markBroken(ctx);
|
|
691
777
|
show(ctx, res);
|
|
692
778
|
// A result that came from outside the user and the workspace is labelled in
|