@r1z4x/tezgah 1.13.0 → 1.14.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 (93) hide show
  1. package/CHANGELOG.md +175 -1
  2. package/MANIFEST +18 -0
  3. package/NOTICE +152 -0
  4. package/README.de.md +41 -20
  5. package/README.es.md +39 -19
  6. package/README.fr.md +41 -20
  7. package/README.md +15 -8
  8. package/README.tr.md +39 -21
  9. package/bin/codegen +9 -2
  10. package/bin/consult +6 -0
  11. package/bin/tezgah-adhd +3 -0
  12. package/bin/tezgah-agents +3 -0
  13. package/bin/tezgah-capture +3 -0
  14. package/bin/tezgah-design +329 -54
  15. package/bin/tezgah-docs +157 -41
  16. package/bin/tezgah-dsh +1 -1
  17. package/bin/tezgah-gate +7 -2
  18. package/bin/tezgah-import-ai-research +16 -4
  19. package/bin/tezgah-index +3 -0
  20. package/bin/tezgah-lessons +13 -4
  21. package/bin/tezgah-mcp +4 -0
  22. package/bin/tezgah-migrate +3 -0
  23. package/bin/tezgah-ocr-review +2 -1
  24. package/bin/tezgah-optimize +4 -1
  25. package/bin/tezgah-pony +3 -0
  26. package/bin/tezgah-research +12 -6
  27. package/bin/tezgah-rollback +3 -0
  28. package/bin/tezgah-route +1 -1
  29. package/bin/tezgah-setup +596 -157
  30. package/bin/tezgah-status +11 -1
  31. package/bin/tezgah-task +3 -0
  32. package/bin/tezgah.js +10 -3
  33. package/commands/adhd.md +1 -1
  34. package/docs/README.md +19 -8
  35. package/docs/architecture.md +2 -2
  36. package/docs/citations-baseline.json +11 -17
  37. package/docs/clarity-baseline.json +3 -3
  38. package/docs/contract.md +77 -31
  39. package/docs/environment.md +58 -24
  40. package/docs/evidence.md +49 -19
  41. package/docs/gate.md +71 -21
  42. package/docs/hosts.md +65 -52
  43. package/docs/layers.md +1 -1
  44. package/docs/operations.md +67 -24
  45. package/docs/research.md +1 -1
  46. package/docs/skills.md +31 -8
  47. package/docs/status-line.md +33 -23
  48. package/docs/testing.md +48 -17
  49. package/docs/vision.md +6 -2
  50. package/hooks/lang/en.json +5 -2
  51. package/hooks/lang/tr.json +3 -2
  52. package/hooks/projects-posttooluse.py +11 -3
  53. package/hooks/projects-stop.py +1 -1
  54. package/hooks/tezgah_context.py +40 -23
  55. package/hooks/tezgah_gate.py +404 -106
  56. package/hooks/tezgah_integrity.py +367 -97
  57. package/hooks/tezgah_lessons.py +5 -2
  58. package/hooks/tezgah_paths.py +17 -6
  59. package/hooks/tezgah_policy.py +13 -5
  60. package/hooks/tezgah_skill_pick.py +1 -1
  61. package/hooks/tezgah_snapshot.py +6 -3
  62. package/hooks/tezgah_stopspec.py +2 -1
  63. package/hosts/amp/hook.py +1 -1
  64. package/hosts/amp/tezgah-plugin.ts.in +13 -3
  65. package/hosts/antigravity/hook.py +1 -1
  66. package/hosts/codex/hook.py +1 -1
  67. package/hosts/commandcode/hook.py +1 -1
  68. package/hosts/copilot/hook.py +1 -1
  69. package/hosts/cursor/hook.py +2 -1
  70. package/hosts/droid/hook.py +1 -1
  71. package/hosts/gemini/hook.py +1 -1
  72. package/hosts/goose/hook.py +1 -1
  73. package/hosts/omp/hook.py +1 -1
  74. package/hosts/omp/tezgah-hook.ts.in +127 -35
  75. package/hosts/opencode/plugins/tezgah.js +209 -63
  76. package/hosts/qwen/hook.py +1 -1
  77. package/output-styles/tezgah.md +1 -1
  78. package/package.json +6 -3
  79. package/packaging/build.sh +4 -4
  80. package/packaging/install.sh +11 -0
  81. package/packaging/upgrade.sh +3 -0
  82. package/skills/ai-research/LICENSE +21 -0
  83. package/skills/design-contract/SKILL.md +27 -6
  84. package/skills/design-library/LICENSE +21 -0
  85. package/skills/plan-add/render_table.py +7 -0
  86. package/skills/pm-frameworks/LICENSE +21 -0
  87. package/skills/rl-env/LICENSE +201 -0
  88. package/skills/tezgah-contract/SKILL.md +2 -2
  89. package/third_party/open-code-review/LICENSE +201 -0
  90. package/third_party/open-code-review/README.md +15 -0
  91. package/third_party/open-code-review/prompts/main_task_system.md +26 -0
  92. package/third_party/open-code-review/prompts/review_filter_task_system.md +13 -0
  93. package/workflows/graph-map.js +14 -5
@@ -78,7 +78,8 @@ def _test_names(path):
78
78
  def _enforced(value, root):
79
79
  """Whether the enforcer a retired lesson names is armed now: a test only
80
80
  while `<root>/tests/test_x.py` still defines it at top level, a gate rule
81
- only while neither its own switch nor `pretooluse-off` is set."""
81
+ only while none of its own switches (one, or a tuple) nor `pretooluse-off`
82
+ is set."""
82
83
  test = re.match(r"tests\.(test_\w+)\.(\w+)$", value)
83
84
  if test:
84
85
  return test.group(2) in _test_names(
@@ -86,7 +87,9 @@ def _enforced(value, root):
86
87
  from tezgah_gate import DENY_RULES # lazy: only a retired line pays for it
87
88
  if value not in DENY_RULES or off("pretooluse-off"):
88
89
  return False
89
- return not (DENY_RULES[value] and off(DENY_RULES[value]))
90
+ switches = DENY_RULES[value]
91
+ return not any(off(s) for s in (
92
+ (switches,) if isinstance(switches, str) else switches or ()))
90
93
 
91
94
 
92
95
  def parse(line):
@@ -1020,12 +1020,21 @@ def say(code=None):
1020
1020
  # `authorized` row, the stand-down text) - the control rule refuses the routes
1021
1021
  # the gate sees, and an interpreter writing them is SECURITY.md's residual.
1022
1022
  STAND_DOWN = "tezgah-setup --uninstall --full\n"
1023
- _LATCH = {"ledger": None, "since": None}
1023
+ # A subagent session also binds its parent's ledger (B-036, OQ-11): it latches
1024
+ # on the earlier of the two first rows, so a switch the parent made mid-session
1025
+ # is not honoured by a subagent it spawns afterwards, and the user's prompt to
1026
+ # the parent authorizes a switch for the subagent too.
1027
+ _LATCH = {"ledger": None, "parent": None, "since": None}
1024
1028
 
1025
1029
 
1026
- def latch(ledger):
1027
- """Bind this process's `off()` to one session's ledger path (None unbinds)."""
1028
- _LATCH.update(ledger=ledger, since=None)
1030
+ def latch(ledger, parent=None):
1031
+ """Bind this process's `off()` to one session's ledger path (None unbinds),
1032
+ and to the ledger of the session that spawned it. Rebinding the same
1033
+ ledger with no parent keeps the one an earlier bind named: the gate's own
1034
+ bind runs after the host adapter's, which read the payload."""
1035
+ if parent is None and ledger and ledger == _LATCH["ledger"]:
1036
+ parent = _LATCH["parent"]
1037
+ _LATCH.update(ledger=ledger, parent=parent, since=None)
1029
1038
 
1030
1039
 
1031
1040
  def _first_ts(ledger):
@@ -1066,8 +1075,10 @@ def _honored(path, name):
1066
1075
  ledger = _LATCH["ledger"]
1067
1076
  if not ledger:
1068
1077
  return True
1078
+ parent = _LATCH["parent"]
1069
1079
  if _LATCH["since"] is None:
1070
- _LATCH["since"] = _first_ts(ledger)
1080
+ firsts = (_first_ts(ledger), _first_ts(parent) if parent else None)
1081
+ _LATCH["since"] = min((t for t in firsts if t is not None), default=None)
1071
1082
  since = _LATCH["since"]
1072
1083
  if since is None or max(t for st in stats for t in (st.st_mtime, st.st_ctime)) \
1073
1084
  < since + 1:
@@ -1079,7 +1090,7 @@ def _honored(path, name):
1079
1090
  return True
1080
1091
  except (OSError, UnicodeDecodeError):
1081
1092
  pass
1082
- return name in _authorized(ledger)
1093
+ return name in _authorized(ledger) or bool(parent) and name in _authorized(parent)
1083
1094
 
1084
1095
 
1085
1096
  def off(name):
@@ -25,7 +25,7 @@ import sys
25
25
 
26
26
  sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
27
27
  import tezgah_research as tr # noqa: E402 (the layer's own line resolver)
28
- from tezgah_paths import say # noqa: E402 (the language packs' contract words)
28
+ from tezgah_paths import CONFIG_DIR, HOME, say # noqa: E402 (the language packs' contract words)
29
29
 
30
30
  # The tezgah CLIs the injected text names, by placeholder. `render` fills each
31
31
  # with `tezgah_paths.tool(<name>)`; the hookless copies (REPLY_LANG_HOOKLESS)
@@ -60,7 +60,7 @@ summary is not the whole contract.
60
60
  Level: `{PONY_BIN} lite|full|ultra` (bare call shows it; stored in
61
61
  ~/.config/tezgah/ponytail.level, machine-wide until changed). The default `full`
62
62
  adds nothing to this reminder; a non-default level is named in it every turn.
63
- Off: user says "stop ponytail".
63
+ Off: user says "stop ponytail", or `ponytail-auto.off` / the repo's `.no-ponytail`.
64
64
  """
65
65
 
66
66
  ADHD = """
@@ -843,7 +843,7 @@ first; write what was cut only when you consciously cut something the user asked
843
843
  for, in at most three lines. In force from the first turn; levels
844
844
  lite|full|ultra are set with `{PONY_BIN}` and ride the per-turn reminder when
845
845
  not `full`; read the full `ponytail` skill from the router for the level table
846
- and examples. Off: "stop ponytail".
846
+ and examples. Off: "stop ponytail", `ponytail-auto.off` or `.no-ponytail`.
847
847
 
848
848
  **Output shape: ADHD-friendly.** The answer or the next action is on the first
849
849
  line - a command, path, snippet or the decision itself, never context or the
@@ -1140,7 +1140,7 @@ item) goes to the user in the approval batch, never answered by the
1140
1140
  orchestrator or escalated to a stronger model. Off: `orchestrate-off`.
1141
1141
 
1142
1142
  **Kill switches:** each one removes its own rule from this text, not just the
1143
- status mark. `~/.config/tezgah/`: `exec-mode.off`, `orchestrate-off`,
1143
+ status mark. `{SWITCH_DIR}`: `exec-mode.off`, `orchestrate-off`,
1144
1144
  `consult-off`, `research-off`, `ponytail-auto.off`, `adhd-off`, `spec-off`, `design-off`,
1145
1145
  `reminder-off`, `verify-off` (the integrity rule: its prompt text, the shortcut
1146
1146
  denials and the Stop gate), `task-off` (the task rule), `judge-off` (the
@@ -1212,10 +1212,18 @@ tezgah refusal and another session's work wait for the owner's answer;
1212
1212
  leftover work is done now, not listed - only a {DECISION}/{ACCESS}/{IRREVERSIBLE}/{ENV}
1213
1213
  blocker with its evidence goes under "{BLOCKERS_HEADING}"; no
1214
1214
  AI/model attribution in any persisted or published artifact. Full
1215
- detail: the tezgah-contract skill.{PONY_LEVEL} Kill switches under ~/.config/tezgah/.
1215
+ detail: the tezgah-contract skill.{PONY_LEVEL} Kill switches under {SWITCH_DIR}.
1216
1216
  </harness-reminder>
1217
1217
  """
1218
1218
 
1219
+ # The kill-switch dir CORE and PROMPT_REMINDER name: CONFIG_DIR, which follows
1220
+ # XDG_CONFIG_HOME, spelled from `~` when it sits under HOME, so the default
1221
+ # reads `~/.config/tezgah/` on every install and the static copies match it.
1222
+ SWITCH_DIR = ("~" + CONFIG_DIR[len(HOME):] if CONFIG_DIR.startswith(HOME + os.sep)
1223
+ else CONFIG_DIR).replace(os.sep, "/") + "/"
1224
+ CORE = CORE.replace("{SWITCH_DIR}", SWITCH_DIR)
1225
+ PROMPT_REMINDER = PROMPT_REMINDER.replace("{SWITCH_DIR}", SWITCH_DIR)
1226
+
1219
1227
  # The blocks above are the on-demand full contract, shipped hand-kept as
1220
1228
  # skills/tezgah-contract/SKILL.md; CORE stays the always-on summary. There is no
1221
1229
  # joined constant: the cost report measures the shipped skill itself, and
@@ -44,7 +44,7 @@ prompt the judgement did not answer, over an index cached in SQLite and rebuilt
44
44
  only when a SKILL.md's stat moves, and appends one line naming the top section
45
45
  when it carries enough of the prompt's words - each section once per session.
46
46
  No credential and no arming file, so `judge-off` does not reach it (it is not a
47
- judgement); `reminder-off` drops it with the rest of the per-turn text."""
47
+ judgement), and neither does `reminder-off`, which drops only the reminder line."""
48
48
 
49
49
 
50
50
  import hashlib
@@ -131,17 +131,20 @@ def _append(session_id, row):
131
131
  """Append one ledger row, including the keys tezgah_integrity.note() drops.
132
132
 
133
133
  `note()` keeps only the LEDGER_FIELDS keys and truncates `detail` to 200
134
- chars, so a row carrying a sha256 - and a path longer than the truncation -
134
+ chars, so a path longer than the truncation - and a rollback's `forced` -
135
135
  cannot go through it. This writes the same file that writer names, from the
136
136
  same derivation (`tezgah_integrity._path`), so there is one ledger and one
137
137
  rule for where it is; every reader treats an unknown key as nothing, so the
138
- extra `hash` costs no existing reader anything. Best effort, like `note()`:
139
- a write failure is not the caller's failure."""
138
+ extra `forced` costs no existing reader anything. It stamps `v`
139
+ (`ROW_VERSION`) as `note_path` does, so the row's shape is named across a
140
+ version change. Best effort, like `note()`: a write failure is not the
141
+ caller's failure."""
140
142
  if not session_id:
141
143
  return
142
144
  try:
143
145
  # through the ledger's own writer: its flock, its torn-tail repair and
144
146
  # its owner-only modes (audit SEC-05 / L-6), not a second open()
147
+ row = dict(row, v=ti.ROW_VERSION)
145
148
  ti._append(ti._path(session_id),
146
149
  json.dumps({k: v for k, v in row.items() if v is not None})
147
150
  + "\n")
@@ -205,7 +205,8 @@ class Trace:
205
205
  or (bool(ti.UI_TOOL_CMD.search(self.masked(i)))
206
206
  and row.get("exit") in (None, 0) and not row.get("fail_class")))
207
207
  if name == "Dc":
208
- return self.col("U")[i] and bool(ti.DESIGN_COMPONENT.search(self.masked(i)))
208
+ return self.col("U")[i] and any(
209
+ ti.DESIGN_COMPONENT.search(p) for p in ti._ui_writes(row))
209
210
  if name == "Dk":
210
211
  # a `slop` verdict only counts for the repository it scanned (review
211
212
  # of plan 133, finding 5): a scan of some other tree proves nothing
package/hosts/amp/hook.py CHANGED
@@ -151,7 +151,7 @@ def handle(payload):
151
151
  event = payload.get("event") or ""
152
152
  cwd = payload.get("cwd") or os.getcwd()
153
153
  session_id = payload.get("session_id")
154
- safe(session_id, bind_session, session_id) # the switch latch (tezgah_paths.off)
154
+ safe(session_id, bind_session, session_id, payload.get("parent")) # the switch latch
155
155
  if event == "status":
156
156
  return status_line(cwd, session_id)
157
157
  if event == "pre_tool_use":
@@ -211,9 +211,13 @@ export default function tezgahAmp(amp: any): void {
211
211
  if (res?.idx) st.idx = res.idx;
212
212
  if (typeof res?.status === "string" && res.status) paint(thread, res.status);
213
213
  };
214
+ // A subagent thread names its parent on every payload: the switch latch
215
+ // counts from the earlier of the two threads' first rows (B-036). `status`
216
+ // reads the parent session.start or an earlier event already asked for.
214
217
  const refresh = async (thread: string): Promise<void> => {
215
218
  if (!active(thread)) return;
216
- show(thread, await ask(thread, { event: "status", cwd: cwd(), session_id: thread }));
219
+ show(thread, await ask(thread, { event: "status", cwd: cwd(), session_id: thread,
220
+ parent: state(thread).parent ?? undefined }));
217
221
  };
218
222
 
219
223
  // A failed hook is told once per thread: a notification now, a hidden line
@@ -271,6 +275,7 @@ export default function tezgahAmp(amp: any): void {
271
275
  const thread = threadOf(event, ctx);
272
276
  // session.start cannot inject: the brief rides this thread's next turn
273
277
  state(thread).briefed = false;
278
+ await parentOf(thread, ctx);
274
279
  await refresh(thread);
275
280
  tell(thread, ctx);
276
281
  } catch { /* a handler never throws */ }
@@ -296,7 +301,8 @@ export default function tezgahAmp(amp: any): void {
296
301
  // tezgah's own continue is not a user prompt: no turn row, no grant
297
302
  if (prompt.trim() && !prompt.startsWith(MARK)) {
298
303
  const res = await ask(thread, { event: "user_prompt", cwd: cwd(), session_id: thread,
299
- prompt, subagent: parent !== null });
304
+ prompt, subagent: parent !== null,
305
+ parent: parent ?? undefined });
300
306
  if (res.context) parts.push(String(res.context));
301
307
  }
302
308
  tell(thread, ctx);
@@ -315,8 +321,10 @@ export default function tezgahAmp(amp: any): void {
315
321
  return { action: "allow" };
316
322
  }
317
323
  const thread = threadOf(event, ctx);
324
+ const parent = await parentOf(thread, ctx);
318
325
  const res = await ask(thread, { event: "pre_tool_use", cwd: cwd(), session_id: thread,
319
- tool, input: gateInput(event) });
326
+ tool, input: gateInput(event),
327
+ parent: parent ?? undefined });
320
328
  tell(thread, ctx);
321
329
  if (typeof res.deny === "string" && res.deny) {
322
330
  return { action: "reject-and-continue", message: res.deny };
@@ -339,8 +347,10 @@ export default function tezgahAmp(amp: any): void {
339
347
  // carries an integer one; the hook never counts a bare `done` as a pass.
340
348
  const exit = output && typeof output === "object" && !Array.isArray(output)
341
349
  && Number.isInteger(output.exitCode) ? output.exitCode : undefined;
350
+ const parent = await parentOf(thread, ctx);
342
351
  const res = await ask(thread, {
343
352
  event: "post_tool_use", cwd: cwd(), session_id: thread, tool,
353
+ parent: parent ?? undefined,
344
354
  input: gateInput(event), status: event?.status, exit_code: exit,
345
355
  result_len: text === undefined ? undefined : text.length,
346
356
  empty_run: text !== undefined && EMPTY_RUN.test(text.slice(-EMPTY_RUN_TAIL)) || undefined,
@@ -210,7 +210,7 @@ def main():
210
210
  first = paths[0] if isinstance(paths, list) and paths else None
211
211
  cwd = first if isinstance(first, str) and first else os.getcwd()
212
212
  session_id = payload.get("conversationId")
213
- safe(session_id, bind_session, session_id) # the switch latch (tezgah_paths.off)
213
+ safe(session_id, bind_session, session_id, payload.get("parent")) # the switch latch
214
214
  # the core's own field names; transcriptPath is left out: its format is not
215
215
  # the one the disarmed-gate counter reads (`tezgah_context.gate_inactive`)
216
216
  norm = {"session_id": session_id, "cwd": cwd}
@@ -168,7 +168,7 @@ def main():
168
168
  # not tell this is a codex session without it.
169
169
  if session_id:
170
170
  os.environ.setdefault("CODEX_THREAD_ID", str(session_id))
171
- safe(session_id, bind_session, session_id) # the switch latch (tezgah_paths.off)
171
+ safe(session_id, bind_session, session_id, payload.get("parent")) # the switch latch
172
172
  if event == "SessionStart":
173
173
  safe(session_id, attest_session, "codex", session_id, cwd)
174
174
 
@@ -195,7 +195,7 @@ def main():
195
195
  event = payload.get("hook_event_name") or ""
196
196
  cwd = payload.get("cwd") or os.getcwd()
197
197
  session_id = payload.get("session_id")
198
- safe(session_id, bind_session, session_id) # the switch latch (tezgah_paths.off)
198
+ safe(session_id, bind_session, session_id, payload.get("parent")) # the switch latch
199
199
  out = {}
200
200
  if event == "PreToolUse":
201
201
  name = payload.get("tool_name", "") or ""
@@ -228,7 +228,7 @@ def main():
228
228
  event = payload.get("hook_event_name") or (sys.argv[1] if len(sys.argv) > 1 else "")
229
229
  cwd = payload.get("cwd") or os.getcwd()
230
230
  session_id = payload.get("session_id") or payload.get("sessionId")
231
- safe(session_id, bind_session, session_id) # the switch latch (tezgah_paths.off)
231
+ safe(session_id, bind_session, session_id, payload.get("parent")) # the switch latch
232
232
  out = {}
233
233
  if event == "PreToolUse":
234
234
  name = payload.get("tool_name", "") or ""
@@ -302,7 +302,8 @@ def dispatch(payload):
302
302
  cwd = cwd_of(payload)
303
303
  session_id = (payload.get("conversation_id") or payload.get("session_id")
304
304
  or payload.get("parent_conversation_id"))
305
- bind_session(session_id) # the switch latch (tezgah_paths.off); dispatch runs in safe()
305
+ # the switch latch (tezgah_paths.off); dispatch runs in safe()
306
+ bind_session(session_id, payload.get("parent_conversation_id"))
306
307
  kind = classify(payload, event)
307
308
  quiet = off("reminder-off")
308
309
 
@@ -207,7 +207,7 @@ def main():
207
207
  event = payload.get("hook_event_name") or ""
208
208
  cwd = payload.get("cwd") or os.getcwd()
209
209
  session_id = payload.get("session_id")
210
- safe(session_id, bind_session, session_id) # the switch latch (tezgah_paths.off)
210
+ safe(session_id, bind_session, session_id, payload.get("parent")) # the switch latch
211
211
  out = {}
212
212
  if event == "PreToolUse":
213
213
  name = payload.get("tool_name", "") or ""
@@ -176,7 +176,7 @@ def main():
176
176
  event = payload.get("hook_event_name") or ""
177
177
  cwd = payload.get("cwd") or os.getcwd()
178
178
  session_id = payload.get("session_id")
179
- safe(session_id, bind_session, session_id) # the switch latch (tezgah_paths.off)
179
+ safe(session_id, bind_session, session_id, payload.get("parent")) # the switch latch
180
180
  out = {}
181
181
  if event == "BeforeTool":
182
182
  reason = safe(session_id, decision, gate_name(payload.get("tool_name", "") or ""),
@@ -107,7 +107,7 @@ def main():
107
107
  event = payload.get("event") or ""
108
108
  cwd = payload.get("working_dir") or os.getcwd()
109
109
  session_id = payload.get("session_id")
110
- safe(session_id, bind_session, session_id) # the switch latch (tezgah_paths.off)
110
+ safe(session_id, bind_session, session_id, payload.get("parent")) # the switch latch
111
111
  out = {}
112
112
  if event == "PreToolUse":
113
113
  name = payload.get("tool_name", "") or ""
package/hosts/omp/hook.py CHANGED
@@ -141,7 +141,7 @@ def handle(payload):
141
141
  event = payload.get("event") or ""
142
142
  cwd = payload.get("cwd") or os.getcwd()
143
143
  session_id = payload.get("session_id")
144
- safe(session_id, bind_session, session_id) # the switch latch (tezgah_paths.off)
144
+ safe(session_id, bind_session, session_id, payload.get("parent")) # the switch latch
145
145
  if event == "status":
146
146
  # turn end and the session switch: the probe runs, so the idx mark is
147
147
  # answered at every turn boundary
@@ -301,6 +301,68 @@ function labelled(content: any, label: string): any {
301
301
  return undefined;
302
302
  }
303
303
 
304
+ // The calls one omp tool call carries, as the core names them (B-010). omp
305
+ // routes an MCP call through its device tool - `write` to `xd://mcp__<name>`
306
+ // with the call's arguments as JSON in `content` - and the core's MCP rules,
307
+ // the MCP_EFFECT spawn filter and the Stop rule's `external` row are keyed on
308
+ // the real name, so the device form is read as `mcp__<name>` with the parsed
309
+ // arguments (an unparsable body stays readable as `content`). The URL is
310
+ // matched the way omp resolves it: trimmed, the scheme in any case. `ast_edit`
311
+ // (direct or as `xd://ast_edit`) only stages its rewrite and omp applies it
312
+ // later through `write xd://resolve`, so the gate reads its targets here: one
313
+ // `edit` per `paths` item, the ops' replacement text as the new content, marked
314
+ // `via: "ast_edit"` - only such an item is read as a glob or a directory.
315
+ // `outer` keeps the device `write` itself in front, for the gate only: its
316
+ // content rules (a credential, a skip) read the whole body whatever class the
317
+ // carried call is, as they did before the route (review 153 F1). The gate
318
+ // writes no `began` for it (tezgah_gate.ROUTED_DEVICE): its result is recorded
319
+ // as the call it carries.
320
+ const DEVICE = /^xd:\/\/(mcp__[^/?#]+|ast_edit)$/i;
321
+ function deviceOf(tool: string, args: any): RegExpExecArray | null {
322
+ return tool.toLowerCase() === "write" ? DEVICE.exec(String(args?.path ?? "").trim()) : null;
323
+ }
324
+ function routed(tool: string, args: any, outer = false): Array<[string, any]> {
325
+ let name = tool;
326
+ let input = args ?? {};
327
+ const device = deviceOf(name, input);
328
+ const calls: Array<[string, any]> = device && outer ? [[name, input]] : [];
329
+ if (device) {
330
+ name = device[1].toLowerCase() === "ast_edit" ? "ast_edit"
331
+ : "mcp__" + device[1].slice("mcp__".length);
332
+ const raw = String(input?.content ?? "");
333
+ try {
334
+ const parsed = JSON.parse(raw);
335
+ input = parsed && typeof parsed === "object" ? parsed : { content: raw };
336
+ } catch {
337
+ input = { content: raw };
338
+ }
339
+ }
340
+ if (name.toLowerCase() !== "ast_edit") return [...calls, [name, input]];
341
+ const ops = Array.isArray(input?.ops) ? input.ops : [];
342
+ const out = ops.map((op: any) => String(op?.out ?? "")).join("\n");
343
+ const paths = Array.isArray(input?.paths) ? input.paths : [];
344
+ return [...calls, ...paths.map((p: any): [string, any] =>
345
+ ["edit", { path: String(p), new_string: out, via: "ast_edit" }])];
346
+ }
347
+
348
+ // omp emits a device call and then, under the same id, the native event of the
349
+ // call it carries: the device event leaves one key per carried call it passed
350
+ // and that native event consumes it, so a row, a label and a retry count are
351
+ // written once (review 153 F4). Nothing else is skipped: a direct call leaves
352
+ // no key, and a reused id, an empty one or a refused call reaches the gate and
353
+ // the ledger again (review 153 round 2 #1). ponytail: the newest 512 keys.
354
+ const CARRIED = { call: new Set<string>(), result: new Set<string>() };
355
+ function carriedKey(id: any, tool: string, input: any): string | null {
356
+ if (id == null || id === "") return null;
357
+ return `${id}:${tool.toLowerCase()}:${String(input?.path ?? "")}`;
358
+ }
359
+ function carry(seen: Set<string>, keys: string[]): void {
360
+ for (const key of keys) {
361
+ seen.add(key);
362
+ if (seen.size > 512) seen.delete(seen.values().next().value as string);
363
+ }
364
+ }
365
+
304
366
  // A check's own output saying it ran nothing: the same literal as
305
367
  // hooks/tezgah_integrity.py `EMPTY_RUN` (tests/test_omp_extension.py pins the
306
368
  // two equal), read here because the bridge sends the hook a size and never the
@@ -473,6 +535,13 @@ function isSubagent(ctx: any): boolean {
473
535
  }
474
536
  }
475
537
 
538
+ // A subagent's events name the main session as `parent`, as its session_start
539
+ // does: the switch latch counts from the earlier of the two sessions' first
540
+ // rows, so a switch the parent made mid-session holds no sway here (B-036).
541
+ function parentOf(ctx: any): string | undefined {
542
+ return isSubagent(ctx) ? process.env.TEZGAH_SESSION || undefined : undefined;
543
+ }
544
+
476
545
  // The session an omp fork was forked from: a main session whose header still
477
546
  // names a `parentSession` (an id, or a `<stamp>_<id>.jsonl` path). The hook
478
547
  // writes it as a `forked` row, so the race rule names a fork as one instead of
@@ -588,7 +657,8 @@ export default function tezgahOmp(pi: any): void {
588
657
  // A failed hook answers nothing, so the last good line would keep claiming a
589
658
  // gate that is not running: the widget is repainted with the failure instead.
590
659
  const refresh = (ctx: any): void => {
591
- const res = ask({ event: "status", cwd: cwdOf(ctx), session_id: sessionOf(ctx) });
660
+ const res = ask({ event: "status", cwd: cwdOf(ctx), session_id: sessionOf(ctx),
661
+ parent: parentOf(ctx) });
592
662
  if (hookError) return markBroken(ctx);
593
663
  show(ctx, res);
594
664
  };
@@ -719,7 +789,7 @@ export default function tezgahOmp(pi: any): void {
719
789
  // `subagent`: a task prompt is the parent's brief, never the user's grant
720
790
  const res = ask({ event: "user_prompt", cwd: cwdOf(ctx), prompt,
721
791
  session_id: session, transcript_path: sessionFileOf(ctx),
722
- subagent: isSubagent(ctx) });
792
+ subagent: isSubagent(ctx), parent: parentOf(ctx) });
723
793
  if (hookError) return noticeMessage(ctx, session);
724
794
  if (!res.context) return undefined;
725
795
  // A hidden message, not a system-prompt rewrite: omp keeps per-request
@@ -730,38 +800,44 @@ export default function tezgahOmp(pi: any): void {
730
800
  });
731
801
 
732
802
  pi.on("tool_call", async (event: any, ctx: any) => {
733
- const tool = String(event?.toolName ?? event?.name ?? "");
734
803
  // An MCP tool is named per server (`mcp__<server>_<tool>`), so no exact
735
804
  // list can hold it: an effect verb in the name reaches the gate, whose
736
- // content rules read its payload (tezgah_integrity.mcp_class).
737
- if (!GATED.includes(tool.toLowerCase()) && !MCP_EFFECT.test(tool)) {
738
- return undefined;
739
- }
740
- const res = ask({ event: "pre_tool_use", cwd: cwdOf(ctx), tool,
741
- input: event?.input ?? event?.args ?? {},
742
- session_id: sessionOf(ctx) });
743
- if (hookError) {
744
- markBroken(ctx);
745
- return undefined; // a hook that cannot answer cannot refuse either
746
- }
747
- if (res.deny) return { block: true, reason: res.deny };
748
- if (res.advice && event?.toolCallId != null) {
749
- ADVICE.set(String(event.toolCallId), String(res.advice));
805
+ // content rules read its payload (tezgah_integrity.mcp_class). A device
806
+ // call is judged as itself and as the call it carries (`routed`), and the
807
+ // native event of a carried call it passed is not asked twice (`CARRIED`).
808
+ const callId = event?.toolCallId;
809
+ const name = String(event?.toolName ?? event?.name ?? "");
810
+ const args = event?.input ?? event?.args ?? {};
811
+ const device = deviceOf(name, args) != null;
812
+ const carried: string[] = [];
813
+ for (const [n, [tool, input]] of routed(name, args, true).entries()) {
814
+ if (!GATED.includes(tool.toLowerCase()) && !MCP_EFFECT.test(tool)) continue;
815
+ const key = carriedKey(callId, tool, input);
816
+ if (device) {
817
+ if (n > 0 && key) carried.push(key); // the device `write` itself is first
818
+ } else if (key && CARRIED.call.delete(key)) {
819
+ continue;
820
+ }
821
+ const res = ask({ event: "pre_tool_use", cwd: cwdOf(ctx), tool, input,
822
+ session_id: sessionOf(ctx), parent: parentOf(ctx) });
823
+ if (hookError) {
824
+ markBroken(ctx);
825
+ return undefined; // a hook that cannot answer cannot refuse either
826
+ }
827
+ if (res.deny) return { block: true, reason: res.deny };
828
+ if (res.advice && event?.toolCallId != null) {
829
+ ADVICE.set(String(event.toolCallId), String(res.advice));
830
+ }
750
831
  }
832
+ carry(CARRIED.call, carried);
751
833
  return undefined;
752
834
  });
753
835
 
754
836
  pi.on("tool_result", async (event: any, ctx: any) => {
755
- const tool = String(event?.toolName ?? "");
756
- const args = event?.input ?? {};
757
837
  const id = event?.toolCallId != null ? String(event.toolCallId) : "";
758
838
  const advice = ADVICE.get(id);
759
839
  ADVICE.delete(id);
760
840
  const graph = advice ? { additionalContext: advice } : undefined;
761
- if (!WATCHED.includes(tool.toLowerCase()) && !tool.startsWith("mcp__")
762
- && !skillRead(tool, args)) {
763
- return graph;
764
- }
765
841
  // Tri-state: an unread outcome must never be written as a passed check,
766
842
  // because the Stop rule below reads this ledger as evidence.
767
843
  const failed = typeof event?.isError === "boolean" ? event.isError : null;
@@ -775,18 +851,34 @@ export default function tezgahOmp(pi: any): void {
775
851
  const content = event?.content;
776
852
  const resultLen = typeof content === "string" ? content.length
777
853
  : Array.isArray(content) ? content.length : undefined;
778
- // omp's todo call holds one op and its result's `details.phases` the whole
779
- // list, so that list crosses for this one tool: the queue the Stop rule and
780
- // the idle-wait warning read (tezgah_integrity.note_todo).
781
- const phases = tool.toLowerCase() === "todo" ? event?.details?.phases : undefined;
782
- const todo = Array.isArray(phases)
783
- ? phases.flatMap((p: any) => (Array.isArray(p?.tasks) ? p.tasks : []))
784
- : undefined;
785
- const res = ask({ event: "post_tool_use", cwd: cwdOf(ctx), tool,
786
- input: event?.input ?? {}, failed, idx: idxGlyph,
787
- session_id: sessionOf(ctx), result_len: resultLen,
788
- empty_run: emptyRun(content) || undefined, todo });
789
- if (hookError) return markBroken(ctx);
854
+ let res: any;
855
+ // a device call is recorded as the call it carries (`routed`): an MCP
856
+ // query's answer is an `external` row, never an edit of `xd://...`
857
+ const name = String(event?.toolName ?? "");
858
+ const input = event?.input ?? {};
859
+ const device = deviceOf(name, input) != null;
860
+ for (const [tool, args] of routed(name, input)) {
861
+ if (!WATCHED.includes(tool.toLowerCase()) && !tool.startsWith("mcp__")
862
+ && !skillRead(tool, args)) {
863
+ continue;
864
+ }
865
+ const key = carriedKey(event?.toolCallId, tool, args);
866
+ if (!device && key && CARRIED.result.delete(key)) continue;
867
+ // omp's todo call holds one op and its result's `details.phases` the whole
868
+ // list, so that list crosses for this one tool: the queue the Stop rule and
869
+ // the idle-wait warning read (tezgah_integrity.note_todo).
870
+ const phases = tool.toLowerCase() === "todo" ? event?.details?.phases : undefined;
871
+ const todo = Array.isArray(phases)
872
+ ? phases.flatMap((p: any) => (Array.isArray(p?.tasks) ? p.tasks : []))
873
+ : undefined;
874
+ res = ask({ event: "post_tool_use", cwd: cwdOf(ctx), tool,
875
+ input: args, failed, idx: idxGlyph,
876
+ session_id: sessionOf(ctx), parent: parentOf(ctx), result_len: resultLen,
877
+ empty_run: emptyRun(content) || undefined, todo });
878
+ if (hookError) return markBroken(ctx);
879
+ if (device && key) carry(CARRIED.result, [key]);
880
+ }
881
+ if (!res) return graph;
790
882
  show(ctx, res);
791
883
  // A result that came from outside the user and the workspace is labelled in
792
884
  // place; every other result is left exactly as the host produced it.