@r1z4x/tezgah 1.6.0 → 1.7.0

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 (64) hide show
  1. package/CHANGELOG.md +80 -1
  2. package/MANIFEST +5 -0
  3. package/README.md +6 -3
  4. package/bin/tezgah-docs +18 -16
  5. package/bin/tezgah-doctor +152 -0
  6. package/bin/tezgah-research +234 -10
  7. package/bin/tezgah-route +48 -1
  8. package/bin/tezgah-setup +26 -13
  9. package/bin/tezgah-task +589 -17
  10. package/bin/tezgah-taste +48 -10
  11. package/docs/architecture.md +7 -4
  12. package/docs/clarity-baseline.json +2 -2
  13. package/docs/contract.md +108 -24
  14. package/docs/evidence.md +201 -28
  15. package/docs/feature-audit.md +1 -1
  16. package/docs/gate.md +60 -22
  17. package/docs/glossary.md +4 -4
  18. package/docs/hosts.md +8 -8
  19. package/docs/index.json +11 -5
  20. package/docs/judge.md +9 -3
  21. package/docs/models.md +35 -2
  22. package/docs/operations.md +103 -3
  23. package/docs/research.md +346 -13
  24. package/docs/skills.md +1 -1
  25. package/docs/status-line.md +5 -5
  26. package/docs/testing.md +2 -2
  27. package/docs/vision.md +12 -4
  28. package/hooks/hooks.json +2 -2
  29. package/hooks/lang/en.json +323 -0
  30. package/hooks/lang/tr.json +344 -0
  31. package/hooks/projects-posttooluse.py +24 -5
  32. package/hooks/projects-pretooluse.py +9 -4
  33. package/hooks/projects-stop.py +28 -16
  34. package/hooks/tezgah_agents.py +29 -10
  35. package/hooks/tezgah_clarity.py +7 -3
  36. package/hooks/tezgah_context.py +425 -406
  37. package/hooks/tezgah_gate.py +583 -33
  38. package/hooks/tezgah_integrity.py +1480 -197
  39. package/hooks/tezgah_models.py +337 -15
  40. package/hooks/tezgah_paths.py +103 -10
  41. package/hooks/tezgah_policy.py +258 -126
  42. package/hooks/tezgah_rank.py +29 -27
  43. package/hooks/tezgah_replay.py +200 -30
  44. package/hooks/tezgah_research.py +1294 -9
  45. package/hooks/tezgah_stopspec.py +7 -0
  46. package/hooks/tezgah_store.py +74 -27
  47. package/hooks/tezgah_task.py +915 -6
  48. package/hosts/codex/hook.py +9 -10
  49. package/hosts/cursor/hook.py +21 -14
  50. package/hosts/dsh/hooks.json +2 -2
  51. package/hosts/omp/hook.py +44 -23
  52. package/hosts/omp/tezgah-hook.ts.in +93 -7
  53. package/hosts/opencode/plugins/tezgah.js +102 -2
  54. package/output-styles/tezgah.md +76 -42
  55. package/package.json +1 -1
  56. package/skills/i-have-adhd/EVALS.md +22 -2
  57. package/skills/i-have-adhd/SKILL.md +12 -5
  58. package/skills/plan-add/SKILL.md +5 -3
  59. package/skills/plan-sync/SKILL.md +5 -3
  60. package/skills/ponytail/EVALS.md +21 -2
  61. package/skills/ponytail/SKILL.md +4 -4
  62. package/skills/research/SKILL.md +89 -1
  63. package/skills/tezgah-contract/EVALS.md +52 -7
  64. package/skills/tezgah-contract/SKILL.md +151 -56
@@ -29,7 +29,7 @@ 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
34
  SUBAGENT_CHANNEL, bind_session, changed_files_notice, note_tool, ran_nothing,
35
35
  report_bytes, stop_reason, untrusted_source)
@@ -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",
@@ -210,7 +206,10 @@ def main():
210
206
  # (hooks/tezgah_taste.write_note), on the same channel
211
207
  taste = (safe(session_id, write_note, session_id, inp, cwd, tool)
212
208
  if root_for(cwd) and not verify_outcome(payload) else None)
213
- notice = "\n".join(p for p in (notice, advice, taste) if p)
209
+ # the cross-project write notice (tezgah_gate.cross_project_note)
210
+ cross = (safe(session_id, cross_project_note, session_id, tool, inp, cwd)
211
+ if root_for(cwd) and not verify_outcome(payload) else None)
212
+ notice = "\n".join(p for p in (notice, advice, taste, cross) if p)
214
213
  if notice:
215
214
  # Codex's PostToolUse output carries `additionalContext` with the
216
215
  # result - the field is part of its own hook output schema
@@ -228,13 +227,13 @@ def main():
228
227
  # {"decision": "block", "reason": ...}, so the integrity rule's second
229
228
  # half runs here too: a done/tested claim with nothing observed behind it
230
229
  # 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).
230
+ # The reply after a block (`stop_hook_active`) is judged again and
231
+ # refused at most once more, for a class this turn was not refused for
232
+ # yet; otherwise it is one `after_block` row (tezgah_integrity._record_only).
233
233
  if not off("verify-off") and root_for(cwd):
234
- blocked = 1 if payload.get("stop_hook_active") else 0
235
234
  reason = safe(session_id, stop_reason,
236
235
  payload.get("last_assistant_message"), session_id,
237
- cwd=cwd, record_only=blocked >= STOP_REASKS)
236
+ cwd=cwd, after_block=bool(payload.get("stop_hook_active")))
238
237
  if reason:
239
238
  out["decision"] = "block"
240
239
  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:
@@ -421,10 +426,12 @@ def dispatch(payload):
421
426
  # Only a finished turn is nudged - an aborted or errored one has no claim
422
427
  # to check - and `loop_count` is the platform's own cap on follow-ups.
423
428
  out = {}
429
+ # The reply a follow-up started is judged again: the core refuses it at
430
+ # most once more, for a class this turn was not refused for yet.
424
431
  if (payload.get("status") in (None, "completed")
425
- and asked(payload) < STOP_REASKS
426
432
  and not off("verify-off") and under(cwd)):
427
- reason = stop_reason(last_answer(session_id), session_id, cwd=cwd)
433
+ reason = stop_reason(last_answer(session_id), session_id, cwd=cwd,
434
+ after_block=asked(payload))
428
435
  if reason:
429
436
  out = {"decision": "block", "reason": reason}
430
437
  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
@@ -40,7 +40,8 @@
40
40
  // second and third halves - the channel on the call's own ledger row (with
41
41
  // the `external` row an MCP answer or a fetched page earns), the provenance
42
42
  // label in front of the result itself, and the taint notice on the first
43
- // effect a turn makes after such a read (hooks/tezgah_untrusted.marks).
43
+ // effect a turn makes after such a read (hooks/tezgah_untrusted.marks); a
44
+ // `task` call also writes its slice record (noteSlice, bin/tezgah-task).
44
45
  //
45
46
  // Known gap: every rule of hooks/tezgah_gate.py is enforced here, but the ledger
46
47
  // the counters read is complete only for the rules ported last - secret, loop,
@@ -1158,6 +1159,49 @@ const DETAIL_MAX = 200
1158
1159
  // agree on the credential itself; they differ only for an astral character
1159
1160
  // inside a quoted named value.
1160
1161
 
1162
+ // The `todo` row for `todowrite` (hooks/tezgah_integrity.note_todo): the list's
1163
+ // open, ready and blocked counts, the ready heads in `heads` and `detail`. Typed
1164
+ // blockers: BLOCKER_SAID/BLOCKER_TRIED's tokens - every hooks/lang/<code>.json
1165
+ // pack's `blockers.<kind>.pattern`, DECISION and KARAR alike - on Unicode word
1166
+ // boundaries (JS `\b` is ASCII-only); no pack found matches none, as `alts`
1167
+ // does. Read once, on the first todo call. ponytail: without the Python
1168
+ // reader's negation rule.
1169
+ const TODO_CLOSED = new Set(["completed", "done", "cancelled", "abandoned"])
1170
+ let todoTyped = null
1171
+ function todoTypedPattern() {
1172
+ if (todoTyped) return todoTyped
1173
+ const kinds = { decision: [], irreversible: [], access: [], env: [] }
1174
+ const dir = hookScript("lang")
1175
+ try {
1176
+ for (const name of dir ? readdirSync(dir).sort() : []) {
1177
+ if (!name.endsWith(".json")) continue
1178
+ const blockers = JSON.parse(readFileSync(join(dir, name), "utf8")).blockers || {}
1179
+ for (const kind of Object.keys(kinds)) {
1180
+ if (blockers[kind]?.pattern) kinds[kind].push(blockers[kind].pattern)
1181
+ }
1182
+ }
1183
+ } catch {}
1184
+ const alt = (...names) => names.flatMap((k) => kinds[k]).join("|") || "(?!)"
1185
+ todoTyped = new RegExp("(?<![\\p{L}\\p{N}_])(?:(?:" + alt("decision", "irreversible")
1186
+ + ")[A-ZÇĞİIÖŞÜ]*|" + alt("access", "env") + ")(?![\\p{L}\\p{N}_])", "u")
1187
+ return todoTyped
1188
+ }
1189
+ async function recordTodo(sessionID, tool, args, workspace) {
1190
+ if (!Array.isArray(args?.todos)) return
1191
+ const open = args.todos.filter((i) => i && String(i.content || "").trim()
1192
+ && !TODO_CLOSED.has(String(i.status || "pending").toLowerCase()))
1193
+ const typed = (i) => todoTypedPattern().test(i.content) || /\bblocked\b/i.test(i.content)
1194
+ const heads = open.filter((i) => String(i.status || "pending").toLowerCase() === "pending"
1195
+ && !typed(i)).map((i) => redact(String(i.content).split(/\s+/).join(" ").trim())
1196
+ .slice(0, 60))
1197
+ await appendRow(sessionID, {
1198
+ kind: "todo", ts: Math.floor(Date.now() / 1000),
1199
+ detail: heads.join("\n").slice(0, DETAIL_MAX),
1200
+ id: actionID(tool, args), workspace: workspace || null, tool,
1201
+ open: open.length, ready: heads.length, blocked: open.filter(typed).length, heads,
1202
+ })
1203
+ }
1204
+
1161
1205
  // Evidence ledger, the store the Python gate and Stop hook read. Written
1162
1206
  // per tool call so a "done/tested" claim can be checked against what ran.
1163
1207
  // The bash tool returns `metadata.exit` (the process exit code), so a check's
@@ -1181,6 +1225,7 @@ async function recordEvidence(sessionID, tool, args, result, workspace, cwd,
1181
1225
  source) {
1182
1226
  if (!sessionID) return
1183
1227
  const t = String(tool || "").toLowerCase()
1228
+ if (t === "todowrite") return await recordTodo(sessionID, tool, args, workspace)
1184
1229
  const exit = result?.metadata?.exit
1185
1230
  let kind = null
1186
1231
  let detail = String(args?.command || writtenPath(args) || "")
@@ -1259,6 +1304,60 @@ async function recordEvidence(sessionID, tool, args, result, workspace, cwd,
1259
1304
  }
1260
1305
  }
1261
1306
 
1307
+ // The slice journal (hooks/tezgah_task.py, plan 081). opencode's `task` call
1308
+ // runs its child to the end before this hook sees it, so one call opens the
1309
+ // record and closes it - done, or stopped with the error's class - through
1310
+ // bin/tezgah-task, the records' one writer. The id is the child session opencode
1311
+ // reports (`metadata.sessionId`), else the first 12 hex digits of the brief's
1312
+ // sha1 (tezgah_task.slice_id). Awaited under the core deadline and never thrown:
1313
+ // a journal that cannot be written costs the record, not the call.
1314
+ const TASK_BIN = join(CONFIG, "bin", "tezgah-task")
1315
+ const SLICE_ID = /^[A-Za-z0-9._-]{1,80}$/
1316
+ const SLICE_BRIEF = 4000
1317
+ // tezgah_task._CLASSES, in its order: the class a stopped record carries, and
1318
+ // `error` for any other failure text.
1319
+ const SLICE_CLASSES = [
1320
+ ["rate_limit", /\b429\b|rate[ _-]?limit/i],
1321
+ ["quota", /quota|key limit exceeded|usage limit|\b40[23]\b.*\blimit|\blimit\b.*\b40[23]\b/i],
1322
+ ["aborted", /abort|cancell?ed/i],
1323
+ ["timeout", /timed out|timeout/i],
1324
+ ["cut_off", /max[ _]turns|max_tokens|cut off|context[ _]length/i],
1325
+ ]
1326
+
1327
+ function sliceClass(text) {
1328
+ const hit = SLICE_CLASSES.find(([, re]) => re.test(text))
1329
+ return hit ? hit[0] : "error"
1330
+ }
1331
+
1332
+ // The error a `task` result reports, as text, or "" for none.
1333
+ function sliceError(result) {
1334
+ for (const e of [result?.error, result?.metadata?.error]) {
1335
+ const text = typeof e === "string" ? e : String(e?.message || e?.name || "")
1336
+ if (e && text.trim()) return text.trim()
1337
+ }
1338
+ return ""
1339
+ }
1340
+
1341
+ async function noteSlice(sessionID, args, result, cwd) {
1342
+ if (!existsSync(TASK_BIN) || await off("slices-off", sessionID)) return
1343
+ const brief = [args?.description, args?.prompt].map((p) => String(p || "").trim())
1344
+ .filter(Boolean).join("\n\n").slice(0, SLICE_BRIEF)
1345
+ if (!brief) return
1346
+ const child = String(result?.metadata?.sessionId || result?.metadata?.session_id || "")
1347
+ const id = SLICE_ID.test(child) ? child
1348
+ : createHash("sha1").update(brief).digest("hex").slice(0, 12)
1349
+ const run = (argv) => collect([TASK_BIN, "slice", ...argv],
1350
+ { cwd, stdio: ["ignore", "pipe", "ignore"] }, null,
1351
+ (code) => code === 0)
1352
+ const opened = await run(["open", "--id", id, "--kind", "agent", "--host", "opencode",
1353
+ "--agent", String(args?.subagent_type || ""),
1354
+ "--brief", brief, "--parent", String(sessionID || ""),
1355
+ "--cwd", cwd])
1356
+ if (!opened) return
1357
+ const error = sliceError(result)
1358
+ await run(error ? ["stop", id, "--class", sliceClass(error)] : ["done", id])
1359
+ }
1360
+
1262
1361
  // How far back the pre-state search reads: the gate writes its snapshot row in
1263
1362
  // the call immediately before the write, so the newest rows are where it is
1264
1363
  // (SNAPSHOT_TAIL in hooks/tezgah_integrity).
@@ -2224,7 +2323,7 @@ const SWITCHES = new Set(["adhd-off", "consult-off", "docs-judge-off",
2224
2323
  "exec-mode.off", "judge-off", "lang-off", "orchestrate-off",
2225
2324
  "ponytail-auto.off", "pretooluse-off", "reminder-off", "research-off",
2226
2325
  "spec-off", "task-off", "triage-off", "verify-off", "workspace-off",
2227
- "agents-off", "skill-suggest-on", "taste-on", "update-check-off"])
2326
+ "agents-off", "skill-suggest-on", "slices-off", "taste-on", "update-check-off"])
2228
2327
 
2229
2328
  // The session latch, mirrored from hooks/tezgah_paths.off: with a session id, a
2230
2329
  // switch file whose ctime or mtime is newer than the session ledger's first row
@@ -2923,6 +3022,7 @@ export const Tezgah = async ({ directory }) => {
2923
3022
  const taste = await recordEvidence(sessionID, tool, args, output, workspace,
2924
3023
  dir, own || inherited)
2925
3024
  if (taste) labelResult(output, taste)
3025
+ if (tool === "task") await noteSlice(sessionID, args, output, dir)
2926
3026
  } catch {}
2927
3027
  },
2928
3028