@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +140 -1
  2. package/MANIFEST +7 -0
  3. package/README.md +6 -3
  4. package/bin/tezgah-decisions +6 -14
  5. package/bin/tezgah-design +21 -5
  6. package/bin/tezgah-docs +18 -16
  7. package/bin/tezgah-doctor +152 -0
  8. package/bin/tezgah-lessons +4 -1
  9. package/bin/tezgah-research +249 -17
  10. package/bin/tezgah-route +70 -8
  11. package/bin/tezgah-setup +26 -13
  12. package/bin/tezgah-task +877 -60
  13. package/bin/tezgah-taste +56 -15
  14. package/docs/architecture.md +28 -11
  15. package/docs/clarity-baseline.json +2 -2
  16. package/docs/contract.md +108 -24
  17. package/docs/environment.md +3 -1
  18. package/docs/evidence.md +209 -28
  19. package/docs/feature-audit.md +1 -1
  20. package/docs/gate.md +78 -23
  21. package/docs/glossary.md +4 -4
  22. package/docs/hosts.md +8 -8
  23. package/docs/index.json +11 -5
  24. package/docs/judge.md +9 -3
  25. package/docs/models.md +75 -14
  26. package/docs/operations.md +133 -3
  27. package/docs/research.md +349 -15
  28. package/docs/skills.md +1 -1
  29. package/docs/status-line.md +5 -5
  30. package/docs/testing.md +20 -13
  31. package/docs/vision.md +12 -4
  32. package/hooks/hooks.json +2 -2
  33. package/hooks/lang/en.json +323 -0
  34. package/hooks/lang/tr.json +344 -0
  35. package/hooks/projects-auto-init.py +29 -1
  36. package/hooks/projects-posttooluse.py +24 -5
  37. package/hooks/projects-pretooluse.py +9 -4
  38. package/hooks/projects-stop.py +28 -16
  39. package/hooks/tezgah_agents.py +53 -17
  40. package/hooks/tezgah_clarity.py +7 -3
  41. package/hooks/tezgah_context.py +489 -438
  42. package/hooks/tezgah_gate.py +996 -80
  43. package/hooks/tezgah_integrity.py +1547 -197
  44. package/hooks/tezgah_models.py +427 -45
  45. package/hooks/tezgah_paths.py +147 -16
  46. package/hooks/tezgah_policy.py +261 -129
  47. package/hooks/tezgah_rank.py +29 -27
  48. package/hooks/tezgah_replay.py +200 -30
  49. package/hooks/tezgah_research.py +1303 -14
  50. package/hooks/tezgah_stopspec.py +7 -0
  51. package/hooks/tezgah_store.py +74 -27
  52. package/hooks/tezgah_task.py +1201 -40
  53. package/hooks/tezgah_taste.py +8 -4
  54. package/hosts/codex/hook.py +32 -13
  55. package/hosts/cursor/hook.py +32 -14
  56. package/hosts/dsh/hooks.json +2 -2
  57. package/hosts/omp/hook.py +44 -23
  58. package/hosts/omp/tezgah-hook.ts.in +93 -7
  59. package/hosts/opencode/plugins/tezgah.js +273 -6
  60. package/output-styles/tezgah.md +76 -42
  61. package/package.json +1 -1
  62. package/skills/i-have-adhd/EVALS.md +22 -2
  63. package/skills/i-have-adhd/SKILL.md +12 -5
  64. package/skills/plan-add/SKILL.md +27 -11
  65. package/skills/plan-add/render_table.py +8 -3
  66. package/skills/plan-sync/SKILL.md +5 -3
  67. package/skills/ponytail/EVALS.md +21 -2
  68. package/skills/ponytail/SKILL.md +4 -4
  69. package/skills/research/SKILL.md +89 -1
  70. package/skills/tezgah-contract/EVALS.md +52 -7
  71. package/skills/tezgah-contract/SKILL.md +154 -59
@@ -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, workspace_from_repo
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. The marker is read first, so the default costs two stats."""
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 `root`, or None when it resolves outside it."""
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
 
@@ -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
- return decision(gate_name(name), inp, cwd, session_id)
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
- notice = "\n".join(p for p in (notice, advice, taste) if p)
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 record-only:
232
- # one `after_block` row, never a second block (STOP_REASKS).
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, record_only=blocked >= STOP_REASKS)
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
@@ -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
- # How many times one stop chain may be refused (hooks/projects-stop.py names the
63
- # same constant): one, deliberately; raising it is owner decision 11. Cursor's
64
- # `stop` payload carries `loop_count` (the follow-ups already sent in this chain)
65
- # and no `stop_hook_active` (cursor-agent 2026.10.01, captured 2026-10-07); the
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 reply with no `conversation_id` is not stored; a
255
- store that cannot be written is not fatal."""
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 + text[-ANSWER_TAIL:]
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
- text = "\n".join(t for t in (notice, reinforce, advice, taste) if t)
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":
@@ -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 bridge hands
23
- the model with this call's result; never a block)
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
- (with `stop_hook_active` the reply is recorded as an `after_block`
35
- row and never blocked a second time)
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 decision, graph_advice # noqa: E402
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
- # How many times one stop chain may be refused (hooks/projects-stop.py names the
72
- # same constant): one, deliberately; raising it is owner decision 11. omp has no
73
- # subagent-end event: `session_stop` does not fire for task sessions.
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) rides the same label
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
- label = "\n".join(t for t in (label, taste) if t)
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
- if (1 if payload.get("stop_hook_active") else 0) >= STOP_REASKS:
262
- # the reply after a block: recorded, never refused a second time,
263
- # under its own guard so a record that fails still answers empty
264
- safe(session_id, stop_reason, payload.get("last_assistant_message"),
265
- session_id, cwd=cwd, record_only=True)
266
- return {}
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