@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.
- package/CHANGELOG.md +175 -1
- package/MANIFEST +18 -0
- package/NOTICE +152 -0
- package/README.de.md +41 -20
- package/README.es.md +39 -19
- package/README.fr.md +41 -20
- package/README.md +15 -8
- package/README.tr.md +39 -21
- package/bin/codegen +9 -2
- package/bin/consult +6 -0
- package/bin/tezgah-adhd +3 -0
- package/bin/tezgah-agents +3 -0
- package/bin/tezgah-capture +3 -0
- package/bin/tezgah-design +329 -54
- package/bin/tezgah-docs +157 -41
- package/bin/tezgah-dsh +1 -1
- package/bin/tezgah-gate +7 -2
- package/bin/tezgah-import-ai-research +16 -4
- package/bin/tezgah-index +3 -0
- package/bin/tezgah-lessons +13 -4
- package/bin/tezgah-mcp +4 -0
- package/bin/tezgah-migrate +3 -0
- package/bin/tezgah-ocr-review +2 -1
- package/bin/tezgah-optimize +4 -1
- package/bin/tezgah-pony +3 -0
- package/bin/tezgah-research +12 -6
- package/bin/tezgah-rollback +3 -0
- package/bin/tezgah-route +1 -1
- package/bin/tezgah-setup +596 -157
- package/bin/tezgah-status +11 -1
- package/bin/tezgah-task +3 -0
- package/bin/tezgah.js +10 -3
- package/commands/adhd.md +1 -1
- package/docs/README.md +19 -8
- package/docs/architecture.md +2 -2
- package/docs/citations-baseline.json +11 -17
- package/docs/clarity-baseline.json +3 -3
- package/docs/contract.md +77 -31
- package/docs/environment.md +58 -24
- package/docs/evidence.md +49 -19
- package/docs/gate.md +71 -21
- package/docs/hosts.md +65 -52
- package/docs/layers.md +1 -1
- package/docs/operations.md +67 -24
- package/docs/research.md +1 -1
- package/docs/skills.md +31 -8
- package/docs/status-line.md +33 -23
- package/docs/testing.md +48 -17
- package/docs/vision.md +6 -2
- package/hooks/lang/en.json +5 -2
- package/hooks/lang/tr.json +3 -2
- package/hooks/projects-posttooluse.py +11 -3
- package/hooks/projects-stop.py +1 -1
- package/hooks/tezgah_context.py +40 -23
- package/hooks/tezgah_gate.py +404 -106
- package/hooks/tezgah_integrity.py +367 -97
- package/hooks/tezgah_lessons.py +5 -2
- package/hooks/tezgah_paths.py +17 -6
- package/hooks/tezgah_policy.py +13 -5
- package/hooks/tezgah_skill_pick.py +1 -1
- package/hooks/tezgah_snapshot.py +6 -3
- package/hooks/tezgah_stopspec.py +2 -1
- package/hosts/amp/hook.py +1 -1
- package/hosts/amp/tezgah-plugin.ts.in +13 -3
- package/hosts/antigravity/hook.py +1 -1
- package/hosts/codex/hook.py +1 -1
- package/hosts/commandcode/hook.py +1 -1
- package/hosts/copilot/hook.py +1 -1
- package/hosts/cursor/hook.py +2 -1
- package/hosts/droid/hook.py +1 -1
- package/hosts/gemini/hook.py +1 -1
- package/hosts/goose/hook.py +1 -1
- package/hosts/omp/hook.py +1 -1
- package/hosts/omp/tezgah-hook.ts.in +127 -35
- package/hosts/opencode/plugins/tezgah.js +209 -63
- package/hosts/qwen/hook.py +1 -1
- package/output-styles/tezgah.md +1 -1
- package/package.json +6 -3
- package/packaging/build.sh +4 -4
- package/packaging/install.sh +11 -0
- package/packaging/upgrade.sh +3 -0
- package/skills/ai-research/LICENSE +21 -0
- package/skills/design-contract/SKILL.md +27 -6
- package/skills/design-library/LICENSE +21 -0
- package/skills/plan-add/render_table.py +7 -0
- package/skills/pm-frameworks/LICENSE +21 -0
- package/skills/rl-env/LICENSE +201 -0
- package/skills/tezgah-contract/SKILL.md +2 -2
- package/third_party/open-code-review/LICENSE +201 -0
- package/third_party/open-code-review/README.md +15 -0
- package/third_party/open-code-review/prompts/main_task_system.md +26 -0
- package/third_party/open-code-review/prompts/review_filter_task_system.md +13 -0
- package/workflows/graph-map.js +14 -5
package/hooks/tezgah_lessons.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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):
|
package/hooks/tezgah_paths.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
package/hooks/tezgah_policy.py
CHANGED
|
@@ -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.
|
|
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
|
|
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)
|
|
47
|
+
judgement), and neither does `reminder-off`, which drops only the reminder line."""
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
import hashlib
|
package/hooks/tezgah_snapshot.py
CHANGED
|
@@ -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
|
|
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 `
|
|
139
|
-
|
|
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")
|
package/hooks/tezgah_stopspec.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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}
|
package/hosts/codex/hook.py
CHANGED
|
@@ -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
|
|
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
|
|
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 ""
|
package/hosts/copilot/hook.py
CHANGED
|
@@ -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
|
|
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 ""
|
package/hosts/cursor/hook.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/hosts/droid/hook.py
CHANGED
|
@@ -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
|
|
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 ""
|
package/hosts/gemini/hook.py
CHANGED
|
@@ -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
|
|
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 ""),
|
package/hosts/goose/hook.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
const
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
const
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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.
|