@r1z4x/tezgah 1.11.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 +507 -1
- package/MANIFEST +40 -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 +18 -10
- 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 +158 -42
- 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 +51 -10
- package/bin/tezgah-rollback +3 -0
- package/bin/tezgah-route +1 -1
- package/bin/tezgah-setup +1741 -95
- package/bin/tezgah-status +11 -1
- package/bin/tezgah-task +58 -17
- 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 +12 -6
- package/docs/contract.md +83 -34
- package/docs/environment.md +59 -24
- package/docs/evidence.md +128 -25
- package/docs/gate.md +77 -23
- package/docs/glossary.md +1 -1
- package/docs/hosts.md +343 -48
- package/docs/layers.md +2 -2
- package/docs/models.md +6 -0
- package/docs/operations.md +83 -29
- package/docs/research.md +37 -11
- package/docs/skills.md +31 -8
- package/docs/status-line.md +39 -23
- package/docs/testing.md +50 -17
- package/docs/vision.md +6 -2
- package/hooks/hooks.json +1 -1
- package/hooks/lang/en.json +16 -2
- package/hooks/lang/tr.json +14 -2
- package/hooks/projects-posttooluse.py +11 -3
- package/hooks/projects-stop.py +3 -2
- package/hooks/tezgah_agents.py +27 -3
- package/hooks/tezgah_attest.py +79 -10
- package/hooks/tezgah_context.py +61 -31
- package/hooks/tezgah_gate.py +480 -126
- package/hooks/tezgah_integrity.py +577 -133
- package/hooks/tezgah_lessons.py +5 -2
- package/hooks/tezgah_paths.py +60 -8
- package/hooks/tezgah_policy.py +48 -18
- package/hooks/tezgah_replay.py +8 -2
- package/hooks/tezgah_research.py +167 -24
- package/hooks/tezgah_skill_pick.py +1 -1
- package/hooks/tezgah_snapshot.py +6 -3
- package/hooks/tezgah_stack.py +236 -1
- package/hooks/tezgah_stopspec.py +2 -1
- package/hooks/tezgah_task.py +2 -1
- package/hosts/amp/hook.py +217 -0
- package/hosts/amp/tezgah-plugin.ts.in +396 -0
- package/hosts/antigravity/hook.py +255 -0
- package/hosts/codex/hook.py +3 -2
- package/hosts/commandcode/hook.py +239 -0
- package/hosts/copilot/hook.py +284 -0
- package/hosts/cursor/hook.py +4 -2
- package/hosts/droid/hook.py +259 -0
- package/hosts/dsh/hooks.json +1 -1
- package/hosts/gemini/hook.py +213 -0
- package/hosts/goose/hook.py +145 -0
- package/hosts/omp/hook.py +5 -2
- package/hosts/omp/tezgah-hook.ts.in +157 -42
- package/hosts/opencode/plugins/tezgah.js +209 -63
- package/hosts/qwen/hook.py +206 -0
- package/output-styles/tezgah.md +11 -6
- 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/harness/SKILL.md +2 -0
- package/skills/i-have-adhd/SKILL.md +10 -1
- package/skills/plan-add/SKILL.md +5 -1
- package/skills/plan-add/render_table.py +7 -0
- package/skills/pm-frameworks/LICENSE +21 -0
- package/skills/research/SKILL.md +22 -9
- package/skills/rl-env/LICENSE +201 -0
- package/skills/tezgah-contract/SKILL.md +21 -8
- package/statusline.py +20 -9
- 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/workflows/graph-review.js +14 -2
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// tezgah: Amp plugin rendered by tezgah-setup from hosts/amp/tezgah-plugin.ts.in
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
4
|
+
import { delimiter, dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
export const description = "tezgah: the contract, the tool gate, the evidence ledger and the Stop rule";
|
|
7
|
+
|
|
8
|
+
// tezgah-setup substitutes the absolute path of hosts/amp/hook.py, the python
|
|
9
|
+
// half that owns the contract, the gate and the evidence ledger. This file is
|
|
10
|
+
// only the bridge: Amp's plugin events in (ampcode.com/docs plugin-api), Amp's
|
|
11
|
+
// own results back. No `@ampcode/plugin` import, so node's type stripping
|
|
12
|
+
// loads it as Amp's Bun does.
|
|
13
|
+
const HOOK = "@HOOK@";
|
|
14
|
+
|
|
15
|
+
// A turn tezgah's own Stop rule started: its prompt is tezgah's reason, never
|
|
16
|
+
// the user's words or a grant, and its end is judged as the re-ask it is.
|
|
17
|
+
const MARK = "[tezgah:stop]";
|
|
18
|
+
// Per hook call: the Stop judgement may read the transcript, so it gets more.
|
|
19
|
+
const HOOK_MS = 10000;
|
|
20
|
+
const STOP_MS = 30000;
|
|
21
|
+
const FAIL = "hook\u2717 tezgah hook failed";
|
|
22
|
+
|
|
23
|
+
// The interpreter, resolved as hooks/tezgah_paths.python_cmd() does: the
|
|
24
|
+
// override, then the names the platforms ship. Memoised after the first ask.
|
|
25
|
+
let pythonMemo: string | undefined;
|
|
26
|
+
function pythonBin(): string {
|
|
27
|
+
if (pythonMemo) return pythonMemo;
|
|
28
|
+
if (process.env.TEZGAH_PYTHON) return (pythonMemo = process.env.TEZGAH_PYTHON);
|
|
29
|
+
const names = ["python3", "python", "py"];
|
|
30
|
+
const dirs = String(process.env.PATH || "").split(delimiter);
|
|
31
|
+
const win = process.platform === "win32";
|
|
32
|
+
const found = names.find((name) => dirs.some((dir) => {
|
|
33
|
+
try {
|
|
34
|
+
return existsSync(join(dir, name)) || (win && existsSync(join(dir, name + ".exe")));
|
|
35
|
+
} catch { return false; }
|
|
36
|
+
}));
|
|
37
|
+
return (pythonMemo = found || names[0]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Amp runs one plugin process for many threads at once, so every bit of state
|
|
41
|
+
// is keyed by the thread id - the session id the ledger is written under.
|
|
42
|
+
type ThreadState = {
|
|
43
|
+
error: string; // the last hook failure, "" when the hook answered
|
|
44
|
+
told: boolean; // the failure was already told on this thread
|
|
45
|
+
notice: boolean; // a told failure still owed to the thread's next turn
|
|
46
|
+
briefed: boolean; // the session brief already rode a turn
|
|
47
|
+
parent?: string | null; // the parent thread, null for a main thread
|
|
48
|
+
idx?: string; // the idx glyph the last probed status carried
|
|
49
|
+
};
|
|
50
|
+
const THREADS = new Map<string, ThreadState>();
|
|
51
|
+
function state(thread: string): ThreadState {
|
|
52
|
+
let st = THREADS.get(thread);
|
|
53
|
+
if (!st) {
|
|
54
|
+
st = { error: "", told: false, notice: false, briefed: false };
|
|
55
|
+
THREADS.set(thread, st);
|
|
56
|
+
}
|
|
57
|
+
return st;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The reason names the hook's last stderr line (a traceback ends on the
|
|
61
|
+
// exception), else what the spawn reported.
|
|
62
|
+
function why(stderr: string, fallback: string): string {
|
|
63
|
+
const lines = stderr.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
64
|
+
return (lines.length ? lines[lines.length - 1] : fallback).slice(0, 160) || "no answer";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// One JSON in, one JSON out, spawned asynchronously: one thread's hook never
|
|
68
|
+
// stalls another thread's events. A missing interpreter, a crash, a timeout or
|
|
69
|
+
// bad JSON answers {} - tezgah never turns its own failure into a refusal - and
|
|
70
|
+
// is remembered on the thread instead.
|
|
71
|
+
function ask(thread: string, payload: Record<string, unknown>, ms = HOOK_MS): Promise<any> {
|
|
72
|
+
const st = state(thread);
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
let out = "";
|
|
75
|
+
let err = "";
|
|
76
|
+
let done = false;
|
|
77
|
+
let timer: any;
|
|
78
|
+
const finish = (res: any, failure?: string): void => {
|
|
79
|
+
if (done) return;
|
|
80
|
+
done = true;
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
if (failure) {
|
|
83
|
+
st.error = failure;
|
|
84
|
+
} else {
|
|
85
|
+
st.error = "";
|
|
86
|
+
st.told = false;
|
|
87
|
+
}
|
|
88
|
+
resolve(res);
|
|
89
|
+
};
|
|
90
|
+
let child: any;
|
|
91
|
+
try {
|
|
92
|
+
child = spawn(pythonBin(), [HOOK], { stdio: ["pipe", "pipe", "pipe"] });
|
|
93
|
+
} catch (e: any) {
|
|
94
|
+
finish({}, why("", String(e?.message ?? e)));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
timer = setTimeout(() => {
|
|
98
|
+
try { child.kill("SIGKILL"); } catch { /* already gone */ }
|
|
99
|
+
finish({}, "no answer within " + ms / 1000 + " s");
|
|
100
|
+
}, ms);
|
|
101
|
+
child.stdout.on("data", (d: any) => { out += d; });
|
|
102
|
+
child.stderr.on("data", (d: any) => { err += d; });
|
|
103
|
+
child.on("error", (e: any) => finish({}, why(err, String(e?.message ?? e))));
|
|
104
|
+
child.on("close", (code: number) => {
|
|
105
|
+
if (code !== 0) return finish({}, why(err, "exit " + code));
|
|
106
|
+
try {
|
|
107
|
+
finish(out.trim() ? JSON.parse(out) : {});
|
|
108
|
+
} catch {
|
|
109
|
+
finish({}, "the hook printed no JSON");
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
child.stdin.on("error", () => { /* a hook that died before reading */ });
|
|
113
|
+
child.stdin.end(JSON.stringify(payload));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function textOf(output: any): string | undefined {
|
|
118
|
+
if (typeof output === "string") return output;
|
|
119
|
+
if (Array.isArray(output)) {
|
|
120
|
+
return output.filter((p: any) => p?.type === "text" && typeof p.text === "string")
|
|
121
|
+
.map((p: any) => p.text).join("\n");
|
|
122
|
+
}
|
|
123
|
+
if (output && typeof output === "object" && typeof output.output === "string") {
|
|
124
|
+
return output.output;
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A check's own output saying it ran nothing: the same literal as
|
|
130
|
+
// hooks/tezgah_integrity.py `EMPTY_RUN`, read here because the body never
|
|
131
|
+
// crosses to the hook; only the flag does.
|
|
132
|
+
const EMPTY_RUN = /^[=\s]*(?:collected 0 items|no tests ran|ran 0 tests|no tests found)\b/im;
|
|
133
|
+
const EMPTY_RUN_TAIL = 4096;
|
|
134
|
+
|
|
135
|
+
// The text of the newest assistant message: the reply the Stop rule reads.
|
|
136
|
+
function lastReply(messages: any): string | undefined {
|
|
137
|
+
if (!Array.isArray(messages)) return undefined;
|
|
138
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
139
|
+
const m = messages[i];
|
|
140
|
+
if (m?.role !== "assistant") continue;
|
|
141
|
+
return textOf(m.content);
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The label in front of a result's own content, so the model reads where the
|
|
147
|
+
// text came from while it reads it. An unknown shape stays as Amp produced it.
|
|
148
|
+
function labelled(output: any, label: string): any {
|
|
149
|
+
const part = { type: "text", text: label };
|
|
150
|
+
if (typeof output === "string") return label + "\n" + output;
|
|
151
|
+
if (Array.isArray(output) && output.every((p: any) => p?.type === "text")) {
|
|
152
|
+
return [part, ...output];
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The tools the gate reads (A-D4): the shells, the edit tools, Task, and an
|
|
158
|
+
// MCP tool whose name carries an effect verb (tezgah_integrity.MCP_VERBS).
|
|
159
|
+
const SHELLS = ["bash", "shell_command"];
|
|
160
|
+
const GATED = ["bash", "shell_command", "edit_file", "create_file", "apply_patch", "task"];
|
|
161
|
+
const MCP_EFFECT = /^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].*)?$/;
|
|
162
|
+
// The results the ledger records, and the untrusted ones the hook labels.
|
|
163
|
+
const WATCHED = [...GATED, "web_search", "read_web_page", "skill"];
|
|
164
|
+
|
|
165
|
+
// A read of a shipped tezgah skill is the one read whose result moves a mark
|
|
166
|
+
// (hooks/tezgah_context.skill_read_kind); ordinary reads never spawn python.
|
|
167
|
+
const SKILL_MARKS = ["ponytail", "i-have-adhd"];
|
|
168
|
+
let skillsMemo: string[] | undefined;
|
|
169
|
+
function skillRead(tool: string, input: any): boolean {
|
|
170
|
+
if (tool.toLowerCase() !== "read") return false;
|
|
171
|
+
const m = /(?:^|\/)skills\/([^/]+)\/SKILL\.md$/.exec(
|
|
172
|
+
String(input?.path ?? input?.file_path ?? "").replace(/\\/g, "/"));
|
|
173
|
+
if (!m) return false;
|
|
174
|
+
if (!skillsMemo) {
|
|
175
|
+
const dir = join(dirname(dirname(dirname(HOOK))), "skills");
|
|
176
|
+
try {
|
|
177
|
+
skillsMemo = readdirSync(dir).filter((n) => existsSync(join(dir, n, "SKILL.md")));
|
|
178
|
+
} catch { skillsMemo = []; }
|
|
179
|
+
}
|
|
180
|
+
return SKILL_MARKS.includes(m[1]) || skillsMemo.includes(m[1]);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export default function tezgahAmp(amp: any): void {
|
|
184
|
+
// The workspace the thread runs in, else this process's directory.
|
|
185
|
+
const cwd = (): string => {
|
|
186
|
+
try {
|
|
187
|
+
const root = amp.system?.workspaceRoot;
|
|
188
|
+
const path = root ? amp.helpers?.filePathFromURI?.(root) : undefined;
|
|
189
|
+
if (typeof path === "string" && path) return path;
|
|
190
|
+
} catch { /* no workspace: the process directory */ }
|
|
191
|
+
return process.cwd();
|
|
192
|
+
};
|
|
193
|
+
const threadOf = (event: any, ctx: any): string =>
|
|
194
|
+
String(event?.thread?.id ?? ctx?.thread?.id ?? "");
|
|
195
|
+
|
|
196
|
+
// The status item: experimental, TUI only, plain text; the owner approved
|
|
197
|
+
// building on it (plan 140 A-D15). One per process, so it shows the thread
|
|
198
|
+
// the user is looking at and nothing else.
|
|
199
|
+
let item: any;
|
|
200
|
+
try { item = amp.experimental?.createStatusItem?.(); } catch { item = undefined; }
|
|
201
|
+
const active = (thread: string): boolean => {
|
|
202
|
+
try { return item !== undefined && amp.activeThread?.current?.id === thread; } catch { return false; }
|
|
203
|
+
};
|
|
204
|
+
const paint = (thread: string, text: string): void => {
|
|
205
|
+
if (!active(thread)) return;
|
|
206
|
+
try { item.update({ text }); } catch { /* cosmetic */ }
|
|
207
|
+
};
|
|
208
|
+
const show = (thread: string, res: any): void => {
|
|
209
|
+
const st = state(thread);
|
|
210
|
+
if (st.error) return paint(thread, FAIL);
|
|
211
|
+
if (res?.idx) st.idx = res.idx;
|
|
212
|
+
if (typeof res?.status === "string" && res.status) paint(thread, res.status);
|
|
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.
|
|
217
|
+
const refresh = async (thread: string): Promise<void> => {
|
|
218
|
+
if (!active(thread)) return;
|
|
219
|
+
show(thread, await ask(thread, { event: "status", cwd: cwd(), session_id: thread,
|
|
220
|
+
parent: state(thread).parent ?? undefined }));
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// A failed hook is told once per thread: a notification now, a hidden line
|
|
224
|
+
// on the thread's next turn, and the failure mark on the status item.
|
|
225
|
+
const tell = (thread: string, ctx: any): void => {
|
|
226
|
+
const st = state(thread);
|
|
227
|
+
if (!st.error) return;
|
|
228
|
+
paint(thread, FAIL);
|
|
229
|
+
if (st.told) return;
|
|
230
|
+
st.told = true;
|
|
231
|
+
st.notice = true;
|
|
232
|
+
try {
|
|
233
|
+
Promise.resolve(ctx?.ui?.notify?.("tezgah hook failed: " + st.error)).catch(() => {});
|
|
234
|
+
} catch { /* `amp -x` has no UI */ }
|
|
235
|
+
};
|
|
236
|
+
const notice = (thread: string): string | undefined => {
|
|
237
|
+
const st = state(thread);
|
|
238
|
+
if (!st.notice) return undefined;
|
|
239
|
+
st.notice = false;
|
|
240
|
+
return "tezgah: its Amp hook failed, so the gate, the evidence ledger and the " +
|
|
241
|
+
"status line are off this thread. Reason: " + st.error + ". Tell the user in " +
|
|
242
|
+
"one line and carry on with their task; do not debug, reinstall or reconfigure " +
|
|
243
|
+
"tezgah mid-session (the user can check it with `" + pythonBin() + " " + HOOK +
|
|
244
|
+
"` or `tezgah-setup --report`).";
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// A thread created with a parent is a subagent: its brief is the subagent
|
|
248
|
+
// one, and its end is recorded, never refused. Asked once per thread.
|
|
249
|
+
const parentOf = async (thread: string, ctx: any): Promise<string | null> => {
|
|
250
|
+
const st = state(thread);
|
|
251
|
+
if (st.parent !== undefined) return st.parent;
|
|
252
|
+
try {
|
|
253
|
+
const p = await ctx?.thread?.parentThreadID?.();
|
|
254
|
+
st.parent = typeof p === "string" && p ? p : null;
|
|
255
|
+
} catch { st.parent = null; }
|
|
256
|
+
return st.parent;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// The shell text in the field the gate reads (`command`), from Amp's own
|
|
260
|
+
// helper first, else the input's `cmd`; `dir` becomes `cwd`.
|
|
261
|
+
const gateInput = (event: any): Record<string, unknown> => {
|
|
262
|
+
const input: Record<string, unknown> = { ...(event?.input ?? {}) };
|
|
263
|
+
if (!SHELLS.includes(String(event?.tool ?? "").toLowerCase())) return input;
|
|
264
|
+
let sc: any = null;
|
|
265
|
+
try { sc = amp.helpers?.shellCommandFromToolCall?.(event); } catch { sc = null; }
|
|
266
|
+
const command = typeof sc?.command === "string" ? sc.command
|
|
267
|
+
: typeof input.cmd === "string" ? input.cmd : input.command;
|
|
268
|
+
if (typeof command === "string") input.command = command;
|
|
269
|
+
if (typeof sc?.dir === "string" && sc.dir && input.cwd === undefined) input.cwd = sc.dir;
|
|
270
|
+
return input;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
amp.on("session.start", async (event: any, ctx: any) => {
|
|
274
|
+
try {
|
|
275
|
+
const thread = threadOf(event, ctx);
|
|
276
|
+
// session.start cannot inject: the brief rides this thread's next turn
|
|
277
|
+
state(thread).briefed = false;
|
|
278
|
+
await parentOf(thread, ctx);
|
|
279
|
+
await refresh(thread);
|
|
280
|
+
tell(thread, ctx);
|
|
281
|
+
} catch { /* a handler never throws */ }
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
amp.on("agent.start", async (event: any, ctx: any) => {
|
|
285
|
+
try {
|
|
286
|
+
const thread = threadOf(event, ctx);
|
|
287
|
+
const st = state(thread);
|
|
288
|
+
const parent = await parentOf(thread, ctx);
|
|
289
|
+
const parts: string[] = [];
|
|
290
|
+
if (!st.briefed) {
|
|
291
|
+
const res = await ask(thread, {
|
|
292
|
+
event: "session_start", cwd: cwd(), session_id: thread,
|
|
293
|
+
subagent: parent !== null, parent: parent ?? undefined,
|
|
294
|
+
agent: parent !== null ? thread : undefined, line: active(thread),
|
|
295
|
+
ignore_guidance: Boolean(process.env.AMP_IGNORE_GUIDANCE_FILES) });
|
|
296
|
+
if (!st.error) st.briefed = true;
|
|
297
|
+
if (res.context) parts.push(String(res.context));
|
|
298
|
+
show(thread, res);
|
|
299
|
+
}
|
|
300
|
+
const prompt = String(event?.message ?? "");
|
|
301
|
+
// tezgah's own continue is not a user prompt: no turn row, no grant
|
|
302
|
+
if (prompt.trim() && !prompt.startsWith(MARK)) {
|
|
303
|
+
const res = await ask(thread, { event: "user_prompt", cwd: cwd(), session_id: thread,
|
|
304
|
+
prompt, subagent: parent !== null,
|
|
305
|
+
parent: parent ?? undefined });
|
|
306
|
+
if (res.context) parts.push(String(res.context));
|
|
307
|
+
}
|
|
308
|
+
tell(thread, ctx);
|
|
309
|
+
const told = notice(thread);
|
|
310
|
+
if (told) parts.unshift(told);
|
|
311
|
+
return parts.length ? { message: { content: parts.join("\n\n"), display: false } } : {};
|
|
312
|
+
} catch {
|
|
313
|
+
return {};
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
amp.on("tool.call", async (event: any, ctx: any) => {
|
|
318
|
+
try {
|
|
319
|
+
const tool = String(event?.tool ?? "");
|
|
320
|
+
if (!GATED.includes(tool.toLowerCase()) && !MCP_EFFECT.test(tool)) {
|
|
321
|
+
return { action: "allow" };
|
|
322
|
+
}
|
|
323
|
+
const thread = threadOf(event, ctx);
|
|
324
|
+
const parent = await parentOf(thread, ctx);
|
|
325
|
+
const res = await ask(thread, { event: "pre_tool_use", cwd: cwd(), session_id: thread,
|
|
326
|
+
tool, input: gateInput(event),
|
|
327
|
+
parent: parent ?? undefined });
|
|
328
|
+
tell(thread, ctx);
|
|
329
|
+
if (typeof res.deny === "string" && res.deny) {
|
|
330
|
+
return { action: "reject-and-continue", message: res.deny };
|
|
331
|
+
}
|
|
332
|
+
} catch { /* a bridge that cannot answer cannot refuse either */ }
|
|
333
|
+
return { action: "allow" };
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
amp.on("tool.result", async (event: any, ctx: any) => {
|
|
337
|
+
try {
|
|
338
|
+
const tool = String(event?.tool ?? "");
|
|
339
|
+
const low = tool.toLowerCase();
|
|
340
|
+
if (!WATCHED.includes(low) && !low.startsWith("mcp__") && !skillRead(tool, event?.input)) {
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
const thread = threadOf(event, ctx);
|
|
344
|
+
const output = event?.output;
|
|
345
|
+
const text = textOf(output);
|
|
346
|
+
// The outcome, tri-state: an exit code only when Amp's output object
|
|
347
|
+
// carries an integer one; the hook never counts a bare `done` as a pass.
|
|
348
|
+
const exit = output && typeof output === "object" && !Array.isArray(output)
|
|
349
|
+
&& Number.isInteger(output.exitCode) ? output.exitCode : undefined;
|
|
350
|
+
const parent = await parentOf(thread, ctx);
|
|
351
|
+
const res = await ask(thread, {
|
|
352
|
+
event: "post_tool_use", cwd: cwd(), session_id: thread, tool,
|
|
353
|
+
parent: parent ?? undefined,
|
|
354
|
+
input: gateInput(event), status: event?.status, exit_code: exit,
|
|
355
|
+
result_len: text === undefined ? undefined : text.length,
|
|
356
|
+
empty_run: text !== undefined && EMPTY_RUN.test(text.slice(-EMPTY_RUN_TAIL)) || undefined,
|
|
357
|
+
idx: state(thread).idx, line: active(thread) });
|
|
358
|
+
tell(thread, ctx);
|
|
359
|
+
show(thread, res);
|
|
360
|
+
if (typeof res.label === "string" && res.label) {
|
|
361
|
+
const out = labelled(output, res.label);
|
|
362
|
+
if (out !== undefined) {
|
|
363
|
+
return event?.status === "done" ? { status: "done", output: out }
|
|
364
|
+
: { status: event?.status, error: event?.error, output: out };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
} catch { /* the result stays as Amp produced it */ }
|
|
368
|
+
return undefined;
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
amp.on("agent.end", async (event: any, ctx: any) => {
|
|
372
|
+
try {
|
|
373
|
+
const thread = threadOf(event, ctx);
|
|
374
|
+
const parent = await parentOf(thread, ctx);
|
|
375
|
+
// an error or cancelled turn claimed nothing: it is not judged
|
|
376
|
+
if (event?.status !== "done") return undefined;
|
|
377
|
+
const reply = lastReply(event?.messages);
|
|
378
|
+
if (parent !== null) {
|
|
379
|
+
await ask(thread, { event: "subagent_stop", cwd: cwd(), session_id: thread,
|
|
380
|
+
parent, agent: thread, last_assistant_message: reply });
|
|
381
|
+
tell(thread, ctx);
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
const res = await ask(thread, {
|
|
385
|
+
event: "stop", cwd: cwd(), session_id: thread, last_assistant_message: reply,
|
|
386
|
+
stop_hook_active: String(event?.message ?? "").startsWith(MARK) }, STOP_MS);
|
|
387
|
+
tell(thread, ctx);
|
|
388
|
+
await refresh(thread);
|
|
389
|
+
if (res.decision === "block" && typeof res.reason === "string" && res.reason) {
|
|
390
|
+
// Amp's own cap (5 continues in a row) backs up tezgah's re-ask budget
|
|
391
|
+
return { action: "continue", userMessage: MARK + " " + res.reason };
|
|
392
|
+
}
|
|
393
|
+
} catch { /* an unanswerable Stop rule never blocks the turn */ }
|
|
394
|
+
return undefined;
|
|
395
|
+
});
|
|
396
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Antigravity CLI (`agy`) lifecycle hook. Reads Antigravity's JSON on stdin and
|
|
3
|
+
answers in its own output shape, onto the shared tezgah core (hooks/).
|
|
4
|
+
|
|
5
|
+
tezgah's entries live under the top-level `tezgah` key of
|
|
6
|
+
`~/.gemini/config/hooks.json` (`bin/tezgah-setup::ANTIGRAVITY_HOOK_EVENTS`):
|
|
7
|
+
SessionStart, PreInvocation, PreToolUse, PostToolUse and Stop
|
|
8
|
+
(antigravity.google/docs/hooks). The payload names no event, so each command
|
|
9
|
+
passes it as the first argument; anything else is ignored. Inert outside the
|
|
10
|
+
roots. The fields are camelCase: `conversationId` is the session,
|
|
11
|
+
`workspacePaths[0]` the cwd, `toolCall.name`/`toolCall.args` the call, with
|
|
12
|
+
PascalCase argument names (`gate_input`).
|
|
13
|
+
|
|
14
|
+
The always-on core is the managed block in `~/.gemini/GEMINI.md`, shared with
|
|
15
|
+
Gemini CLI, so SessionStart repeats it as an `injectSteps` system message only
|
|
16
|
+
when that block is missing. PreInvocation with `invocationNum` 0 is taken as a
|
|
17
|
+
new user turn: it writes the turn row and carries the per-turn reminder as an
|
|
18
|
+
`ephemeralMessage`. PreToolUse always answers, since `decision` is required:
|
|
19
|
+
`deny` with a reason, else `allow`, which hands the call to the next hook or
|
|
20
|
+
the default policy. PostToolUse carries no tool output, only an `error` string
|
|
21
|
+
(empty when the call succeeded), and has no output channel, so its notices
|
|
22
|
+
reach the ledger only; a shell call never records a pass (`post_tool`).
|
|
23
|
+
|
|
24
|
+
Stop carries no reply text, so it reads the last assistant text from
|
|
25
|
+
`transcriptPath` (`last_reply`); none there is a `reply_unread` row, never
|
|
26
|
+
judged. Only a stop with `fullyIdle` and `terminationReason` `model_stop` is
|
|
27
|
+
judged; `{"decision": "continue", "reason": ...}` sends the model back into the
|
|
28
|
+
loop with the reason as a system message. The status line is
|
|
29
|
+
`tezgah-statusline --antigravity`, Antigravity's `statusLine` command.
|
|
30
|
+
"""
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
|
|
35
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
|
|
36
|
+
sys.path.insert(0, os.path.join(ROOT, "hooks"))
|
|
37
|
+
from tezgah_guard import attest_session, import_failed, safe # noqa: E402
|
|
38
|
+
try:
|
|
39
|
+
from tezgah_context import ( # noqa: E402
|
|
40
|
+
_tail_lines, command_text, context_for, record, shell_kind)
|
|
41
|
+
from tezgah_gate import cross_project_note, decision, graph_advice # noqa: E402
|
|
42
|
+
from tezgah_integrity import ( # noqa: E402
|
|
43
|
+
bind_session, events, note, note_tool, stop_reason)
|
|
44
|
+
from tezgah_paths import HOST_DIRS, off, root_for # noqa: E402
|
|
45
|
+
from tezgah_taste import write_note # noqa: E402
|
|
46
|
+
from tezgah_untrusted import marks # noqa: E402
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
import_failed(exc)
|
|
49
|
+
|
|
50
|
+
# Antigravity's tool names (hooks "Supported tools") -> the gate's vocabulary.
|
|
51
|
+
# `view_file` maps for the ledger only: the PreToolUse matcher never sends it.
|
|
52
|
+
GATE_TOOLS = {
|
|
53
|
+
"run_command": "Bash",
|
|
54
|
+
"write_to_file": "Write",
|
|
55
|
+
"replace_file_content": "Edit",
|
|
56
|
+
"multi_replace_file_content": "MultiEdit",
|
|
57
|
+
"invoke_subagent": "Task",
|
|
58
|
+
"grep_search": "Grep",
|
|
59
|
+
"view_file": "Read",
|
|
60
|
+
}
|
|
61
|
+
# PascalCase argument -> the gate's field, per tool
|
|
62
|
+
ARGS = {
|
|
63
|
+
"run_command": {"CommandLine": "command"},
|
|
64
|
+
"write_to_file": {"TargetFile": "file_path", "CodeContent": "content"},
|
|
65
|
+
"replace_file_content": {"TargetFile": "file_path", "TargetContent": "old_string",
|
|
66
|
+
"ReplacementContent": "new_string"},
|
|
67
|
+
"multi_replace_file_content": {"TargetFile": "file_path"},
|
|
68
|
+
"grep_search": {"SearchPath": "path", "Query": "pattern"},
|
|
69
|
+
"view_file": {"AbsolutePath": "file_path"},
|
|
70
|
+
}
|
|
71
|
+
CHUNK = {"TargetContent": "old_string", "ReplacementContent": "new_string",
|
|
72
|
+
"AllowMultiple": "replace_all"}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def gate_name(name):
|
|
76
|
+
"""The tool name the gate denies under and the ledger row records; both
|
|
77
|
+
halves map through here so a call hashes to one id."""
|
|
78
|
+
return GATE_TOOLS.get(name, name)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def gate_input(name, args):
|
|
82
|
+
"""The tool arguments in the gate's fields. The docs name the arguments of
|
|
83
|
+
`multi_replace_file_content`'s `ReplacementChunks` items nowhere; each is
|
|
84
|
+
read as a `replace_file_content` (TargetContent/ReplacementContent), so an
|
|
85
|
+
unknown chunk shape is judged on the file text beside its strings.
|
|
86
|
+
`invoke_subagent`'s `Subagents` become `tasks` with the `TypeName` as
|
|
87
|
+
`agent`, the batch shape the gate reads a subagent type from."""
|
|
88
|
+
inp = dict(args) if isinstance(args, dict) else {}
|
|
89
|
+
for src, dst in ARGS.get(name, {}).items():
|
|
90
|
+
if src in inp and dst not in inp:
|
|
91
|
+
inp[dst] = inp[src]
|
|
92
|
+
if name == "multi_replace_file_content" and isinstance(inp.get("ReplacementChunks"), list):
|
|
93
|
+
inp["edits"] = [{CHUNK.get(k, k): v for k, v in c.items()} if isinstance(c, dict)
|
|
94
|
+
else c for c in inp["ReplacementChunks"]]
|
|
95
|
+
elif name == "invoke_subagent" and isinstance(inp.get("Subagents"), list):
|
|
96
|
+
inp["tasks"] = [{"agent": s.get("TypeName"), "prompt": s.get("Prompt")}
|
|
97
|
+
for s in inp["Subagents"] if isinstance(s, dict)]
|
|
98
|
+
return inp
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def tool_call(payload):
|
|
102
|
+
call = payload.get("toolCall")
|
|
103
|
+
call = call if isinstance(call, dict) else {}
|
|
104
|
+
name = call.get("name") if isinstance(call.get("name"), str) else ""
|
|
105
|
+
return name, gate_input(name, call.get("args"))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _text(content):
|
|
109
|
+
"""The text blocks of a message's content, a string or a block list."""
|
|
110
|
+
if isinstance(content, str):
|
|
111
|
+
return [content]
|
|
112
|
+
if not isinstance(content, list):
|
|
113
|
+
return []
|
|
114
|
+
return [b.get("text") for b in content if isinstance(b, dict)
|
|
115
|
+
and b.get("type") == "text" and isinstance(b.get("text"), str)]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def last_reply(path):
|
|
119
|
+
"""The text of the assistant reply that ends the transcript, or "".
|
|
120
|
+
|
|
121
|
+
Antigravity documents `transcript.jsonl` only by its path. The parser is
|
|
122
|
+
Command Code's (`hosts/commandcode/hook.py::last_reply`): Claude's
|
|
123
|
+
`{"type": "assistant", "message": {"content": [...]}}` rows and bare
|
|
124
|
+
`{"role": "assistant", "content": ...}` rows; a transcript that ends on a
|
|
125
|
+
user or tool row has no reply written yet and answers ""."""
|
|
126
|
+
parts = []
|
|
127
|
+
for raw in reversed(_tail_lines(os.path.expanduser(path)) if path else []):
|
|
128
|
+
try:
|
|
129
|
+
row = json.loads(raw)
|
|
130
|
+
except ValueError:
|
|
131
|
+
continue
|
|
132
|
+
if not isinstance(row, dict):
|
|
133
|
+
continue
|
|
134
|
+
msg = row.get("message") if isinstance(row.get("message"), dict) else row
|
|
135
|
+
role = row.get("type") if row.get("type") in ("user", "assistant") else msg.get("role")
|
|
136
|
+
if role not in ("user", "assistant", "tool"):
|
|
137
|
+
continue
|
|
138
|
+
if role != "assistant":
|
|
139
|
+
break
|
|
140
|
+
parts[:0] = _text(msg.get("content"))
|
|
141
|
+
return "\n".join(t for t in parts if t.strip())
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def rules_carry_core():
|
|
145
|
+
"""True when the global `~/.gemini/GEMINI.md` holds the managed block."""
|
|
146
|
+
try:
|
|
147
|
+
with open(os.path.join(os.path.dirname(HOST_DIRS["antigravity"]), "GEMINI.md"),
|
|
148
|
+
encoding="utf-8") as fh:
|
|
149
|
+
return "<!-- tezgah:start" in fh.read()
|
|
150
|
+
except OSError:
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def reentered(session_id):
|
|
155
|
+
"""True when the ledger's last row is a Stop refusal: this invocation is the
|
|
156
|
+
loop a `continue` re-entered, not a new user turn, so it writes no turn row
|
|
157
|
+
and the refusal budget keeps counting in the refused turn.
|
|
158
|
+
|
|
159
|
+
ponytail: whether `invocationNum` restarts at 0 on that re-entry is not
|
|
160
|
+
documented; a user prompt sent right after a refusal (an interrupted loop)
|
|
161
|
+
joins the refused turn, which can only refuse less."""
|
|
162
|
+
rows = events(session_id, tail=1) if session_id else []
|
|
163
|
+
return bool(rows and rows[-1].get("reask"))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def post_tool(payload, cwd, session_id):
|
|
167
|
+
"""The ledger row for one finished call. `error` is the runtime error, empty
|
|
168
|
+
when the call succeeded (hooks "PostToolUse"); there is no output text.
|
|
169
|
+
A shell call with an empty `error` is None (ran, never a pass): a command
|
|
170
|
+
handed to the background (`WaitMsBeforeAsync`) also ends with no error, and
|
|
171
|
+
nothing else tells the two apart, so Antigravity never records a pass."""
|
|
172
|
+
name, inp = tool_call(payload)
|
|
173
|
+
tool = gate_name(name)
|
|
174
|
+
safe(session_id, record, session_id,
|
|
175
|
+
"orch" if tool == "Task" else
|
|
176
|
+
shell_kind(command_text(inp)) if tool == "Bash" else None)
|
|
177
|
+
if not root_for(cwd):
|
|
178
|
+
return
|
|
179
|
+
failed = bool(payload.get("error"))
|
|
180
|
+
if tool == "Bash" and not failed:
|
|
181
|
+
failed = None
|
|
182
|
+
got = safe(session_id, marks, tool, inp, session_id, None)
|
|
183
|
+
source = (got or (None, None))[0]
|
|
184
|
+
safe(session_id, note_tool, session_id, tool, inp, failed=failed,
|
|
185
|
+
source=source, cwd=cwd)
|
|
186
|
+
safe(session_id, graph_advice, tool, inp, cwd, "antigravity")
|
|
187
|
+
if not failed:
|
|
188
|
+
safe(session_id, write_note, session_id, inp, cwd, tool)
|
|
189
|
+
safe(session_id, cross_project_note, session_id, tool, inp, cwd)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def inject(kind, text):
|
|
193
|
+
"""`injectSteps` with one step, or {} when there is nothing to say."""
|
|
194
|
+
if not text:
|
|
195
|
+
return {}
|
|
196
|
+
step = {"systemMessage": {"systemMessage": text}} if kind == "system" \
|
|
197
|
+
else {"ephemeralMessage": text}
|
|
198
|
+
return {"injectSteps": [step]}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main():
|
|
202
|
+
try:
|
|
203
|
+
payload = json.load(sys.stdin)
|
|
204
|
+
except Exception:
|
|
205
|
+
return
|
|
206
|
+
if not isinstance(payload, dict):
|
|
207
|
+
return
|
|
208
|
+
event = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
209
|
+
paths = payload.get("workspacePaths")
|
|
210
|
+
first = paths[0] if isinstance(paths, list) and paths else None
|
|
211
|
+
cwd = first if isinstance(first, str) and first else os.getcwd()
|
|
212
|
+
session_id = payload.get("conversationId")
|
|
213
|
+
safe(session_id, bind_session, session_id, payload.get("parent")) # the switch latch
|
|
214
|
+
# the core's own field names; transcriptPath is left out: its format is not
|
|
215
|
+
# the one the disarmed-gate counter reads (`tezgah_context.gate_inactive`)
|
|
216
|
+
norm = {"session_id": session_id, "cwd": cwd}
|
|
217
|
+
out = {}
|
|
218
|
+
if event == "PreToolUse":
|
|
219
|
+
name, inp = tool_call(payload)
|
|
220
|
+
reason = safe(session_id, decision, gate_name(name), inp, cwd, session_id)
|
|
221
|
+
# `decision` is required; `allow` hands the call on to the default policy
|
|
222
|
+
out = {"decision": "deny", "reason": reason} if reason else {"decision": "allow"}
|
|
223
|
+
elif event == "PostToolUse":
|
|
224
|
+
post_tool(payload, cwd, session_id)
|
|
225
|
+
elif event == "SessionStart":
|
|
226
|
+
safe(session_id, attest_session, "antigravity", session_id, cwd)
|
|
227
|
+
out = inject("system", safe(session_id, context_for, "session_start", cwd, norm,
|
|
228
|
+
with_core=not rules_carry_core()))
|
|
229
|
+
elif event == "PreInvocation":
|
|
230
|
+
if payload.get("invocationNum") == 0 and not safe(session_id, reentered, session_id):
|
|
231
|
+
out = inject("ephemeral", safe(session_id, context_for, "user_prompt", cwd, norm))
|
|
232
|
+
elif event == "Stop":
|
|
233
|
+
# `decision` is required; any value but `continue` lets the stop through
|
|
234
|
+
out = {"decision": "allow"}
|
|
235
|
+
if (payload.get("fullyIdle") is True and payload.get("terminationReason") == "model_stop"
|
|
236
|
+
and not off("verify-off") and root_for(cwd)):
|
|
237
|
+
path = payload.get("transcriptPath")
|
|
238
|
+
text = safe(session_id, last_reply, path)
|
|
239
|
+
if text:
|
|
240
|
+
# Antigravity has no block counter; the turn's refusal rows
|
|
241
|
+
# bound the re-asks (tezgah_integrity._record_only)
|
|
242
|
+
reason = safe(session_id, stop_reason, text, session_id, cwd=cwd,
|
|
243
|
+
mode=payload.get("permissionMode"))
|
|
244
|
+
if reason:
|
|
245
|
+
out = {"decision": "continue", "reason": reason}
|
|
246
|
+
else:
|
|
247
|
+
safe(session_id, note, session_id, "reply_unread",
|
|
248
|
+
"no assistant text at the end of %s" % path if path
|
|
249
|
+
else "no transcriptPath")
|
|
250
|
+
else:
|
|
251
|
+
return
|
|
252
|
+
print(json.dumps(out))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
main()
|
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
|
|
|
@@ -253,7 +253,8 @@ def main():
|
|
|
253
253
|
if not off("verify-off") and root_for(cwd):
|
|
254
254
|
reason = safe(session_id, stop_reason,
|
|
255
255
|
payload.get("last_assistant_message"), session_id,
|
|
256
|
-
cwd=cwd, after_block=bool(payload.get("stop_hook_active"))
|
|
256
|
+
cwd=cwd, after_block=bool(payload.get("stop_hook_active")),
|
|
257
|
+
mode=payload.get("permission_mode"))
|
|
257
258
|
if reason:
|
|
258
259
|
out["decision"] = "block"
|
|
259
260
|
out["reason"] = reason
|