@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.
Files changed (109) hide show
  1. package/CHANGELOG.md +507 -1
  2. package/MANIFEST +40 -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 +18 -10
  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 +158 -42
  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 +51 -10
  27. package/bin/tezgah-rollback +3 -0
  28. package/bin/tezgah-route +1 -1
  29. package/bin/tezgah-setup +1741 -95
  30. package/bin/tezgah-status +11 -1
  31. package/bin/tezgah-task +58 -17
  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 +12 -6
  38. package/docs/contract.md +83 -34
  39. package/docs/environment.md +59 -24
  40. package/docs/evidence.md +128 -25
  41. package/docs/gate.md +77 -23
  42. package/docs/glossary.md +1 -1
  43. package/docs/hosts.md +343 -48
  44. package/docs/layers.md +2 -2
  45. package/docs/models.md +6 -0
  46. package/docs/operations.md +83 -29
  47. package/docs/research.md +37 -11
  48. package/docs/skills.md +31 -8
  49. package/docs/status-line.md +39 -23
  50. package/docs/testing.md +50 -17
  51. package/docs/vision.md +6 -2
  52. package/hooks/hooks.json +1 -1
  53. package/hooks/lang/en.json +16 -2
  54. package/hooks/lang/tr.json +14 -2
  55. package/hooks/projects-posttooluse.py +11 -3
  56. package/hooks/projects-stop.py +3 -2
  57. package/hooks/tezgah_agents.py +27 -3
  58. package/hooks/tezgah_attest.py +79 -10
  59. package/hooks/tezgah_context.py +61 -31
  60. package/hooks/tezgah_gate.py +480 -126
  61. package/hooks/tezgah_integrity.py +577 -133
  62. package/hooks/tezgah_lessons.py +5 -2
  63. package/hooks/tezgah_paths.py +60 -8
  64. package/hooks/tezgah_policy.py +48 -18
  65. package/hooks/tezgah_replay.py +8 -2
  66. package/hooks/tezgah_research.py +167 -24
  67. package/hooks/tezgah_skill_pick.py +1 -1
  68. package/hooks/tezgah_snapshot.py +6 -3
  69. package/hooks/tezgah_stack.py +236 -1
  70. package/hooks/tezgah_stopspec.py +2 -1
  71. package/hooks/tezgah_task.py +2 -1
  72. package/hosts/amp/hook.py +217 -0
  73. package/hosts/amp/tezgah-plugin.ts.in +396 -0
  74. package/hosts/antigravity/hook.py +255 -0
  75. package/hosts/codex/hook.py +3 -2
  76. package/hosts/commandcode/hook.py +239 -0
  77. package/hosts/copilot/hook.py +284 -0
  78. package/hosts/cursor/hook.py +4 -2
  79. package/hosts/droid/hook.py +259 -0
  80. package/hosts/dsh/hooks.json +1 -1
  81. package/hosts/gemini/hook.py +213 -0
  82. package/hosts/goose/hook.py +145 -0
  83. package/hosts/omp/hook.py +5 -2
  84. package/hosts/omp/tezgah-hook.ts.in +157 -42
  85. package/hosts/opencode/plugins/tezgah.js +209 -63
  86. package/hosts/qwen/hook.py +206 -0
  87. package/output-styles/tezgah.md +11 -6
  88. package/package.json +6 -3
  89. package/packaging/build.sh +4 -4
  90. package/packaging/install.sh +11 -0
  91. package/packaging/upgrade.sh +3 -0
  92. package/skills/ai-research/LICENSE +21 -0
  93. package/skills/design-contract/SKILL.md +27 -6
  94. package/skills/design-library/LICENSE +21 -0
  95. package/skills/harness/SKILL.md +2 -0
  96. package/skills/i-have-adhd/SKILL.md +10 -1
  97. package/skills/plan-add/SKILL.md +5 -1
  98. package/skills/plan-add/render_table.py +7 -0
  99. package/skills/pm-frameworks/LICENSE +21 -0
  100. package/skills/research/SKILL.md +22 -9
  101. package/skills/rl-env/LICENSE +201 -0
  102. package/skills/tezgah-contract/SKILL.md +21 -8
  103. package/statusline.py +20 -9
  104. package/third_party/open-code-review/LICENSE +201 -0
  105. package/third_party/open-code-review/README.md +15 -0
  106. package/third_party/open-code-review/prompts/main_task_system.md +26 -0
  107. package/third_party/open-code-review/prompts/review_filter_task_system.md +13 -0
  108. package/workflows/graph-map.js +14 -5
  109. 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()
@@ -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
 
@@ -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