@joeytheman/gemini-mcp-tool 1.3.0 → 2.3.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 (172) hide show
  1. package/README.md +127 -35
  2. package/dist/__tests__/agyModelSteps.test.d.ts +2 -0
  3. package/dist/__tests__/agyModelSteps.test.d.ts.map +1 -0
  4. package/dist/__tests__/agyModelSteps.test.js +58 -0
  5. package/dist/__tests__/agyModelSteps.test.js.map +1 -0
  6. package/dist/__tests__/ask-gemini.test.js +99 -37
  7. package/dist/__tests__/ask-gemini.test.js.map +1 -1
  8. package/dist/__tests__/capture-script.chromium.test.d.ts +2 -0
  9. package/dist/__tests__/capture-script.chromium.test.d.ts.map +1 -0
  10. package/dist/__tests__/capture-script.chromium.test.js +235 -0
  11. package/dist/__tests__/capture-script.chromium.test.js.map +1 -0
  12. package/dist/__tests__/layout-snippet.chromium.test.d.ts +2 -0
  13. package/dist/__tests__/layout-snippet.chromium.test.d.ts.map +1 -0
  14. package/dist/__tests__/layout-snippet.chromium.test.js +333 -0
  15. package/dist/__tests__/layout-snippet.chromium.test.js.map +1 -0
  16. package/dist/__tests__/layout-snippet.test.d.ts +2 -0
  17. package/dist/__tests__/layout-snippet.test.d.ts.map +1 -0
  18. package/dist/__tests__/layout-snippet.test.js +12 -0
  19. package/dist/__tests__/layout-snippet.test.js.map +1 -0
  20. package/dist/__tests__/live-pass.test.d.ts +2 -0
  21. package/dist/__tests__/live-pass.test.d.ts.map +1 -0
  22. package/dist/__tests__/live-pass.test.js +1249 -0
  23. package/dist/__tests__/live-pass.test.js.map +1 -0
  24. package/dist/__tests__/screen-review.test.d.ts +2 -0
  25. package/dist/__tests__/screen-review.test.d.ts.map +1 -0
  26. package/dist/__tests__/screen-review.test.js +553 -0
  27. package/dist/__tests__/screen-review.test.js.map +1 -0
  28. package/dist/__tests__/server-progress.test.d.ts +2 -0
  29. package/dist/__tests__/server-progress.test.d.ts.map +1 -0
  30. package/dist/__tests__/server-progress.test.js +63 -0
  31. package/dist/__tests__/server-progress.test.js.map +1 -0
  32. package/dist/__tests__/server.test.js +17 -19
  33. package/dist/__tests__/server.test.js.map +1 -1
  34. package/dist/__tests__/simple-tools.test.d.ts +2 -0
  35. package/dist/__tests__/simple-tools.test.d.ts.map +1 -0
  36. package/dist/__tests__/simple-tools.test.js +30 -0
  37. package/dist/__tests__/simple-tools.test.js.map +1 -0
  38. package/dist/__tests__/tools.test.js +91 -0
  39. package/dist/__tests__/tools.test.js.map +1 -1
  40. package/dist/__tests__/utils/agyExecutor.test.d.ts +2 -0
  41. package/dist/__tests__/utils/agyExecutor.test.d.ts.map +1 -0
  42. package/dist/__tests__/utils/agyExecutor.test.js +731 -0
  43. package/dist/__tests__/utils/agyExecutor.test.js.map +1 -0
  44. package/dist/__tests__/utils/agyTranscriptRecovery.test.d.ts +2 -0
  45. package/dist/__tests__/utils/agyTranscriptRecovery.test.d.ts.map +1 -0
  46. package/dist/__tests__/utils/agyTranscriptRecovery.test.js +127 -0
  47. package/dist/__tests__/utils/agyTranscriptRecovery.test.js.map +1 -0
  48. package/dist/__tests__/utils/chunkCache.test.js +26 -2
  49. package/dist/__tests__/utils/chunkCache.test.js.map +1 -1
  50. package/dist/__tests__/utils/commandExecutor.test.js +185 -7
  51. package/dist/__tests__/utils/commandExecutor.test.js.map +1 -1
  52. package/dist/__tests__/utils/fileReferences.test.d.ts +2 -0
  53. package/dist/__tests__/utils/fileReferences.test.d.ts.map +1 -0
  54. package/dist/__tests__/utils/fileReferences.test.js +87 -0
  55. package/dist/__tests__/utils/fileReferences.test.js.map +1 -0
  56. package/dist/__tests__/utils/gitStatusGuard.test.d.ts +2 -0
  57. package/dist/__tests__/utils/gitStatusGuard.test.d.ts.map +1 -0
  58. package/dist/__tests__/utils/gitStatusGuard.test.js +106 -0
  59. package/dist/__tests__/utils/gitStatusGuard.test.js.map +1 -0
  60. package/dist/__tests__/utils/progressUpdates.test.d.ts +2 -0
  61. package/dist/__tests__/utils/progressUpdates.test.d.ts.map +1 -0
  62. package/dist/__tests__/utils/progressUpdates.test.js +62 -0
  63. package/dist/__tests__/utils/progressUpdates.test.js.map +1 -0
  64. package/dist/__tests__/utils/responseCache.test.js +31 -11
  65. package/dist/__tests__/utils/responseCache.test.js.map +1 -1
  66. package/dist/__tests__/utils/test-helpers.d.ts +4 -0
  67. package/dist/__tests__/utils/test-helpers.d.ts.map +1 -1
  68. package/dist/__tests__/utils/test-helpers.js +16 -0
  69. package/dist/__tests__/utils/test-helpers.js.map +1 -1
  70. package/dist/constants.d.ts +91 -26
  71. package/dist/constants.d.ts.map +1 -1
  72. package/dist/constants.js +101 -28
  73. package/dist/constants.js.map +1 -1
  74. package/dist/index.js +20 -95
  75. package/dist/index.js.map +1 -1
  76. package/dist/layoutCheckSnippet.generated.d.ts +2 -0
  77. package/dist/layoutCheckSnippet.generated.d.ts.map +1 -0
  78. package/dist/layoutCheckSnippet.generated.js +3 -0
  79. package/dist/layoutCheckSnippet.generated.js.map +1 -0
  80. package/dist/tools/ask-gemini.tool.d.ts +34 -0
  81. package/dist/tools/ask-gemini.tool.d.ts.map +1 -1
  82. package/dist/tools/ask-gemini.tool.js +40 -23
  83. package/dist/tools/ask-gemini.tool.js.map +1 -1
  84. package/dist/tools/brainstorm.tool.d.ts.map +1 -1
  85. package/dist/tools/brainstorm.tool.js +7 -5
  86. package/dist/tools/brainstorm.tool.js.map +1 -1
  87. package/dist/tools/index.d.ts.map +1 -1
  88. package/dist/tools/index.js +3 -1
  89. package/dist/tools/index.js.map +1 -1
  90. package/dist/tools/live-pass.tool.d.ts +206 -0
  91. package/dist/tools/live-pass.tool.d.ts.map +1 -0
  92. package/dist/tools/live-pass.tool.js +670 -0
  93. package/dist/tools/live-pass.tool.js.map +1 -0
  94. package/dist/tools/registry.d.ts +6 -4
  95. package/dist/tools/registry.d.ts.map +1 -1
  96. package/dist/tools/registry.js +14 -11
  97. package/dist/tools/registry.js.map +1 -1
  98. package/dist/tools/screen-review.tool.d.ts +124 -0
  99. package/dist/tools/screen-review.tool.d.ts.map +1 -0
  100. package/dist/tools/screen-review.tool.js +449 -0
  101. package/dist/tools/screen-review.tool.js.map +1 -0
  102. package/dist/tools/simple-tools.d.ts.map +1 -1
  103. package/dist/tools/simple-tools.js +9 -3
  104. package/dist/tools/simple-tools.js.map +1 -1
  105. package/dist/utils/agyExecutor.d.ts +76 -0
  106. package/dist/utils/agyExecutor.d.ts.map +1 -0
  107. package/dist/utils/agyExecutor.js +449 -0
  108. package/dist/utils/agyExecutor.js.map +1 -0
  109. package/dist/utils/agyModelSteps.d.ts +5 -0
  110. package/dist/utils/agyModelSteps.d.ts.map +1 -0
  111. package/dist/utils/agyModelSteps.js +33 -0
  112. package/dist/utils/agyModelSteps.js.map +1 -0
  113. package/dist/utils/agyReport.d.ts +19 -0
  114. package/dist/utils/agyReport.d.ts.map +1 -0
  115. package/dist/utils/agyReport.js +62 -0
  116. package/dist/utils/agyReport.js.map +1 -0
  117. package/dist/utils/agyTranscriptRecovery.d.ts +16 -0
  118. package/dist/utils/agyTranscriptRecovery.d.ts.map +1 -0
  119. package/dist/utils/agyTranscriptRecovery.js +149 -0
  120. package/dist/utils/agyTranscriptRecovery.js.map +1 -0
  121. package/dist/utils/artifactFile.d.ts +6 -0
  122. package/dist/utils/artifactFile.d.ts.map +1 -0
  123. package/dist/utils/artifactFile.js +55 -0
  124. package/dist/utils/artifactFile.js.map +1 -0
  125. package/dist/utils/captureScript.d.ts +24 -0
  126. package/dist/utils/captureScript.d.ts.map +1 -0
  127. package/dist/utils/captureScript.js +83 -0
  128. package/dist/utils/captureScript.js.map +1 -0
  129. package/dist/utils/chunkCache.d.ts.map +1 -1
  130. package/dist/utils/chunkCache.js +19 -5
  131. package/dist/utils/chunkCache.js.map +1 -1
  132. package/dist/utils/commandExecutor.d.ts +7 -1
  133. package/dist/utils/commandExecutor.d.ts.map +1 -1
  134. package/dist/utils/commandExecutor.js +162 -22
  135. package/dist/utils/commandExecutor.js.map +1 -1
  136. package/dist/utils/fileReferences.d.ts +45 -0
  137. package/dist/utils/fileReferences.d.ts.map +1 -0
  138. package/dist/utils/fileReferences.js +169 -0
  139. package/dist/utils/fileReferences.js.map +1 -0
  140. package/dist/utils/gitCheckout.d.ts +14 -0
  141. package/dist/utils/gitCheckout.d.ts.map +1 -0
  142. package/dist/utils/gitCheckout.js +36 -0
  143. package/dist/utils/gitCheckout.js.map +1 -0
  144. package/dist/utils/gitStatusGuard.d.ts +35 -0
  145. package/dist/utils/gitStatusGuard.d.ts.map +1 -0
  146. package/dist/utils/gitStatusGuard.js +82 -0
  147. package/dist/utils/gitStatusGuard.js.map +1 -0
  148. package/dist/utils/layoutCheck.d.ts +58 -0
  149. package/dist/utils/layoutCheck.d.ts.map +1 -0
  150. package/dist/utils/layoutCheck.js +84 -0
  151. package/dist/utils/layoutCheck.js.map +1 -0
  152. package/dist/utils/logger.d.ts +0 -1
  153. package/dist/utils/logger.d.ts.map +1 -1
  154. package/dist/utils/logger.js +0 -6
  155. package/dist/utils/logger.js.map +1 -1
  156. package/dist/utils/progressUpdates.d.ts +14 -0
  157. package/dist/utils/progressUpdates.d.ts.map +1 -0
  158. package/dist/utils/progressUpdates.js +40 -0
  159. package/dist/utils/progressUpdates.js.map +1 -0
  160. package/dist/utils/responseCache.d.ts +3 -3
  161. package/dist/utils/responseCache.d.ts.map +1 -1
  162. package/dist/utils/responseCache.js +11 -6
  163. package/dist/utils/responseCache.js.map +1 -1
  164. package/package.json +19 -16
  165. package/dist/__tests__/utils/geminiExecutor.test.d.ts +0 -2
  166. package/dist/__tests__/utils/geminiExecutor.test.d.ts.map +0 -1
  167. package/dist/__tests__/utils/geminiExecutor.test.js +0 -337
  168. package/dist/__tests__/utils/geminiExecutor.test.js.map +0 -1
  169. package/dist/utils/geminiExecutor.d.ts +0 -17
  170. package/dist/utils/geminiExecutor.d.ts.map +0 -1
  171. package/dist/utils/geminiExecutor.js +0 -238
  172. package/dist/utils/geminiExecutor.js.map +0 -1
@@ -0,0 +1,670 @@
1
+ import { z } from 'zod';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { execFile } from 'child_process';
5
+ import { promisify } from 'util';
6
+ import { formatZodIssues } from './registry.js';
7
+ import { deniedActionsSummary, executeAgyJson, formatAgyUsageLine, formatConversationLine, printTimeoutSchema, toAgyJsonSchema, } from '../utils/agyExecutor.js';
8
+ import { bulletList, section, renderUnparsableEnvelope, formatWorkingTreeSection, failWithWorkingTree } from '../utils/agyReport.js';
9
+ import { isWithin, resolveWorkingDirectory, realpathOfExistingPrefix } from '../utils/fileReferences.js';
10
+ import { snapshotWorkingTree, diffWorkingTree } from '../utils/gitStatusGuard.js';
11
+ import { checkArtifactFile, hasImageSignature, IMAGE_FILE } from '../utils/artifactFile.js';
12
+ import { readCheckout } from '../utils/gitCheckout.js';
13
+ import { formatLayoutEntry, layoutFileName, readLayoutCheck } from '../utils/layoutCheck.js';
14
+ import { buildCaptureScript, parseViewport, saveLayoutFunction } from '../utils/captureScript.js';
15
+ import { countAgyModelSteps, formatModelSteps } from '../utils/agyModelSteps.js';
16
+ import { ERROR_MESSAGES, LIVE_PASS, MODELS, MODEL_CHOICE_DESCRIPTION, PRINT_TIMEOUT_CEILING_NOTE } from '../constants.js';
17
+ const execFileAsync = promisify(execFile);
18
+ const PUBLISH_MAX_BUFFER = 10 * 1024 * 1024;
19
+ const PUBLISH_TIMEOUT_MS = 5 * 60 * 1000;
20
+ const FILES_PLACEHOLDER = '{files}';
21
+ // ---------------------------------------------------------------- arguments
22
+ export const livePassTargetSchema = z.object({
23
+ screen: z.string().min(1).describe("Screen or route name, e.g. 'orders' or 'settings/profile'."),
24
+ state: z.string().min(1).describe("State to capture, e.g. 'default', 'empty', 'offline', 'error', 'sheet-open'."),
25
+ viewport: z.string().min(1).describe("Viewport as WxH, e.g. '390x844' or '1280x800', which the playwright driver requires. For the maestro driver, the device form factor."),
26
+ expected: z.string().min(1).describe("What this frame must show for a PASS. For an intentional error/empty/offline state, name the reason text and the recovery affordance."),
27
+ });
28
+ export const livePassArgsSchema = z.object({
29
+ targets: z.array(livePassTargetSchema).min(1).describe("Every frame the pass must produce. The verdict is derived from this list: a target that produced no frame fails the pass, whether Gemini skipped it with a reason or never reported it at all — a skip explains missing evidence, it does not excuse it."),
30
+ brief: z.string().refine(value => value.trim().length > 0, ERROR_MESSAGES.LIVE_PASS_NO_BRIEF)
31
+ .describe("Every fact Gemini needs, because it may not read code to find one: the app's start URL or app id, any link a user arrives by (an invite, email or share link) with where it comes from, the served-branch marker, and per target the user path, the labels or testIDs on it, the seed account and ids, and how long toasts and pending states stay on screen. A target whose fact is missing is skipped with 'brief missing: <fact>'."),
32
+ workingDirectory: z.string().min(1).describe("Absolute path to the worktree under test. Used as the agy cwd and workspace root; must exist and must not be a filesystem root."),
33
+ artifactDir: z.string().optional().describe("Absolute directory for the captured PNGs and manifest.json. Must be strictly inside workingDirectory. Defaults to <workingDirectory>/.live-pass/<timestamp>."),
34
+ driver: z.enum(['playwright', 'maestro']).describe("Which MCP server Gemini drives: 'playwright' for web, 'maestro' for a booted simulator."),
35
+ model: z.string().default(MODELS.MEDIUM).describe(`${MODEL_CHOICE_DESCRIPTION} Defaults to '${MODELS.MEDIUM}'.`),
36
+ printTimeout: printTimeoutSchema.default(LIVE_PASS.DEFAULT_PRINT_TIMEOUT).describe(`agy --print-timeout for the whole walk. A full pass takes minutes. ${PRINT_TIMEOUT_CEILING_NOTE}`),
37
+ yolo: z.boolean().default(true).describe("Pass --dangerously-skip-permissions so Gemini may drive its MCP servers unattended. Without it agy denies the tool calls and the pass is BLOCKED. Refused when workingDirectory is a filesystem root."),
38
+ publishCommand: z.string().optional().describe("Optional shell command run in code after validation, with {files} replaced by the shell-quoted paths of the frames that validated OK. Its stdout is returned as PR markdown. Never run on a BLOCKED pass."),
39
+ conversationId: z.string().min(1).optional().describe("Resume a previous pass so Gemini can report deltas (resolved / regressions / changes) against what it saw then. The browser or simulator still starts fresh."),
40
+ }).superRefine((value, ctx) => {
41
+ const seen = new Set();
42
+ value.targets.forEach((target, index) => {
43
+ const key = targetKey(target);
44
+ if (seen.has(key)) {
45
+ ctx.addIssue({
46
+ code: 'custom',
47
+ message: `duplicate target: ${target.screen} / ${target.state} / ${target.viewport}`,
48
+ path: ['targets', index],
49
+ });
50
+ }
51
+ seen.add(key);
52
+ if (value.driver === 'playwright' && !parseViewport(target.viewport)) {
53
+ ctx.addIssue({
54
+ code: 'custom',
55
+ message: webViewportError(target.viewport),
56
+ path: ['targets', index, 'viewport'],
57
+ });
58
+ }
59
+ });
60
+ });
61
+ function webViewportError(viewport) {
62
+ return `${ERROR_MESSAGES.LIVE_PASS_WEB_VIEWPORT}. Received '${viewport}'.`;
63
+ }
64
+ // ------------------------------------------------------------ result schema
65
+ const frameSchema = z.object({
66
+ file: z.string().describe("Absolute path of the PNG you saved for this frame."),
67
+ screen: z.string().describe("The requested target's screen string, copied exactly."),
68
+ state: z.string().describe("The requested target's state string, copied exactly."),
69
+ expected: z.string().describe("What the brief said this state must show."),
70
+ viewport: z.string().describe("The requested target's viewport string, copied exactly."),
71
+ verdict: z.enum(['PASS', 'FAIL']),
72
+ observed: z.string().describe("What the image actually shows, in your own words, after opening it."),
73
+ link_source: z.string().optional().describe("Only when this target began at a link the brief says the user arrives by: the brief's words naming where that link comes from, quoted."),
74
+ });
75
+ const findingSchema = z.object({
76
+ severity: z.enum(['BLOCKER', 'MAJOR', 'MINOR']),
77
+ screen: z.string(),
78
+ description: z.string(),
79
+ evidence_file: z.string().optional(),
80
+ });
81
+ const noteSchema = z.object({
82
+ screen: z.string(),
83
+ description: z.string(),
84
+ evidence_file: z.string().optional(),
85
+ });
86
+ export const livePassResultSchema = z.object({
87
+ verdict: z.enum(['PASS', 'FAIL', 'BLOCKED']),
88
+ summary: z.string(),
89
+ served_branch_check: z.string().describe("How you confirmed the running app is the branch under test, or why you could not."),
90
+ frames: z.array(frameSchema),
91
+ findings: z.array(findingSchema),
92
+ regressions: z.array(findingSchema).describe("Behaviour that worked in the earlier pass of this conversation and is broken now. Empty on a fresh pass."),
93
+ resolved: z.array(noteSchema).describe("Defects reported earlier in this conversation that are now fixed. Never affects the verdict. Empty on a fresh pass."),
94
+ changes: z.array(noteSchema).describe("Neutral differences from the earlier pass that are neither broken nor a fix. Empty on a fresh pass."),
95
+ skipped: z.array(z.object({ target: z.string(), reason: z.string() })),
96
+ });
97
+ export const LIVE_PASS_RESULT_JSON_SCHEMA = toAgyJsonSchema(livePassResultSchema);
98
+ const LIVE_PASS_RESULT_SCHEMA_TEXT = JSON.stringify(LIVE_PASS_RESULT_JSON_SCHEMA);
99
+ // ------------------------------------------------------------------- prompt
100
+ function normalize(value) {
101
+ return value.trim().toLowerCase().replace(/\s+/g, '');
102
+ }
103
+ function targetKey(target) {
104
+ return [normalize(target.screen), normalize(target.state), normalize(target.viewport)].join('|');
105
+ }
106
+ function slugify(value) {
107
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'x';
108
+ }
109
+ /**
110
+ * The one filename a target's evidence may use. Derived in code from the whole
111
+ * screen/state/viewport triple so two targets can never share a file — letting
112
+ * Gemini name frames allowed one overwritten screenshot to satisfy several
113
+ * targets at once.
114
+ */
115
+ export function frameFileName(target) {
116
+ return `${slugify(target.screen)}-${slugify(target.state)}-${slugify(target.viewport)}.png`;
117
+ }
118
+ /**
119
+ * One filename per target, keyed by target. Slugifying can map two distinct
120
+ * targets (`a/b` and `a-b`) onto one name, so a collision is suffixed rather
121
+ * than left to collapse two targets into one file.
122
+ */
123
+ export function prescribedFrameNames(targets) {
124
+ const used = new Set();
125
+ const byTarget = new Map();
126
+ for (const target of targets) {
127
+ const base = frameFileName(target);
128
+ let name = base;
129
+ for (let suffix = 2; used.has(name); suffix++) {
130
+ name = base.replace(/\.png$/, `-${suffix}.png`);
131
+ }
132
+ used.add(name);
133
+ byTarget.set(targetKey(target), name);
134
+ }
135
+ return byTarget;
136
+ }
137
+ export function prescribedTargetFiles(targets, artifactDir) {
138
+ const files = new Map();
139
+ for (const [key, frameName] of prescribedFrameNames(targets)) {
140
+ files.set(key, {
141
+ frameName,
142
+ frame: path.join(artifactDir, frameName),
143
+ capture: path.join(artifactDir, frameName.replace(/\.png$/, LIVE_PASS.CAPTURE_FILE_SUFFIX)),
144
+ layout: path.join(artifactDir, layoutFileName(frameName)),
145
+ });
146
+ }
147
+ return files;
148
+ }
149
+ export function writeCaptureScripts(targets, artifactDir) {
150
+ const files = prescribedTargetFiles(targets, artifactDir);
151
+ for (const target of targets) {
152
+ const viewport = parseViewport(target.viewport);
153
+ if (!viewport)
154
+ throw new Error(webViewportError(target.viewport));
155
+ const { frameName, frame, capture } = files.get(targetKey(target));
156
+ fs.rmSync(capture, { force: true });
157
+ fs.writeFileSync(capture, buildCaptureScript({ viewport, frameFile: frame, key: frameName }), { flag: 'wx' });
158
+ }
159
+ }
160
+ function renderTargets(targets, artifactDir, driver) {
161
+ const files = prescribedTargetFiles(targets, artifactDir);
162
+ return targets
163
+ .map((target, index) => {
164
+ const { frameName, frame, capture, layout } = files.get(targetKey(target));
165
+ const lines = [
166
+ `${index + 1}. screen: ${target.screen} | state: ${target.state} | viewport: ${target.viewport}`,
167
+ ` expected: ${target.expected}`,
168
+ ` file: ${frame}`,
169
+ ];
170
+ if (driver === 'playwright') {
171
+ lines.push(` capture: ${capture}`, ` layout: ${layout}`, ` save layout: ${saveLayoutFunction(frameName)}`);
172
+ }
173
+ return lines.join('\n');
174
+ })
175
+ .join('\n');
176
+ }
177
+ const PLAYWRIGHT_PROCEDURE = `Driver: the "${LIVE_PASS.PLAYWRIGHT_MCP_NAME}" MCP server.
178
+ browser_navigate opens only the URLs the "User path" section allows.
179
+ For each target, in order:
180
+ - Reach it as the "User path" section says.
181
+ - Locate each control cheaply, in this order: a testID the brief names, as target [data-testid="<testID>"]; then browser_find with its visible label; then browser_snapshot with a target and a small depth scoped to that region. Never take a full-page snapshot just to look around, and never copy a snapshot or accessibility tree into your answer.
182
+ - Native dialogs such as window.confirm and window.alert block the page: accept or dismiss each with browser_handle_dialog right after the click that opened it.
183
+ - Wait until the marker the brief names for that state is present. Never capture a page that still shows a spinner or skeleton when a content state was requested.
184
+ - Capture it: browser_run_code_unsafe with filename set to the exact absolute path printed as "capture:" for that target below. Never pass code, never write, edit or read a capture file, and never reuse another target's file. The capture sets the target's viewport and grows a scrolling screen taller, a few times at most and up to ${LIVE_PASS.CAPTURE_MAX_HEIGHT} px, until its scroll area fits; a screen that still scrolls after that is shot as it stands. It saves the screenshot to that target's "file:" path, measures the layout, and restores the viewport. It answers "saved <file> at <width>x<height>"; an added "; layout not measured: <error>" or "; viewport not restored: <error>" still saved the frame.
185
+ - Right after it, save the layout check: browser_evaluate with function set to the "save layout:" function printed for that target, copied verbatim, and filename set to the exact absolute path printed as "layout:" for that target. Do not open that file or report on it: the caller reads it.
186
+ - If a capture returns an error, add the target to "skipped" with the error text. Never fall back to browser_take_screenshot.
187
+ - Scroll a virtualized list to its end before asserting that something is absent.
188
+ - browser_evaluate runs only the "save layout:" functions and browser_run_code_unsafe only the "capture:" files. Neither is used to click, type, navigate, or set storage, cookies or app state.`;
189
+ const PLAYWRIGHT_CHEAT_SHEET = `## Tool cheat sheet (the installed parameters: never read tool schemas or Playwright source)
190
+ "target" is a ref from browser_find or browser_snapshot (such as e12) or a unique selector such as [data-testid="save"].
191
+ - browser_navigate {url}
192
+ - browser_click {target, element?, doubleClick?, button?, modifiers?}
193
+ - browser_type {target, text, submit?, slowly?}: submit presses Enter afterwards.
194
+ - browser_fill_form {fields: [{target, name, type: textbox | checkbox | radio | combobox | slider, value}]}: a checkbox value is "true" or "false"; a combobox value is the option's text.
195
+ - browser_select_option {target, values: [...]}
196
+ - browser_press_key {key}: such as Enter, Escape, Tab, ArrowDown.
197
+ - browser_handle_dialog {accept, promptText?}
198
+ - browser_wait_for {text? | textGone? | time?}: time is in seconds, at most 30.
199
+ - browser_find {text | regex}: searches the accessibility snapshot and returns the matching nodes with their refs.
200
+ - browser_snapshot {target?, depth?, boxes?}
201
+ - browser_run_code_unsafe {filename}: filename is absolute.
202
+ - browser_evaluate {function, filename}: the return value is written to filename as JSON instead of returned to you.`;
203
+ const MAESTRO_PROCEDURE = `Driver: the "${LIVE_PASS.MAESTRO_MCP_NAME}" MCP server.
204
+ For each target, in order:
205
+ - list_devices first and use the returned device_id for every later call. Never guess a device.
206
+ - Reach each state as the "User path" section says, with inline YAML flows: launchApp only to open the app at the start, then tapOn and inputText; openLink only for a link arrival that section allows.
207
+ - Locate controls by the testID the brief names (id:), then by the full visible text inspect_screen reports (text:).
208
+ - inspect_screen to confirm the state settled.
209
+ - Never use take_screenshot for evidence: it returns an image inline and saves nothing.
210
+ - After each settled state, shell out: xcrun simctl io <device_id, the simulator UDID> screenshot <the exact absolute path printed as "file:" for that target below>. Never invent a filename and never reuse another target's file.
211
+ - Only one driver may own a simulator at a time. If the device is busy, record the target as skipped with that reason rather than fighting for it — the pass then FAILS for missing evidence, which is the honest outcome. Never fabricate or reuse a frame.`;
212
+ const MAESTRO_CHEAT_SHEET = `## Tool cheat sheet (the installed parameters: never read tool schemas)
213
+ - list_devices {}: returns the device_id every other call needs.
214
+ - run {device_id, yaml, env?}: an inline flow, such as "- tapOn:\\n id: save" or "- inputText: hello".
215
+ - inspect_screen {device_id}: the view hierarchy as compact JSON with abbreviated keys (b bounds, txt text, rid resource id, a11y accessibility label). Selectors take id:, text:, index:, below:, above:, leftOf:, rightOf: — never the abbreviated keys. text: is a full-string, case-insensitive regex: copy txt verbatim or end it with ".*".
216
+ - take_screenshot {device_id}: returns an image inline; never evidence.
217
+ - cheat_sheet {}: Maestro flow syntax, only when a command above is not enough.`;
218
+ const RESUMED_DELTA_SECTION = `## Delta reporting (this conversation has an earlier pass)
219
+ Compare against the frames and findings you reported earlier in this conversation.
220
+ - A defect you reported before that is now fixed goes in "resolved".
221
+ - Behaviour that worked before and is broken now goes in "regressions".
222
+ - A difference that is neither (a moved control, changed copy that reads correctly) goes in "changes".
223
+ - A new defect goes in "findings".
224
+ Never put an improvement in "findings" or "regressions". The browser or simulator starts fresh: walk every target again, do not answer from memory.`;
225
+ function renderCheckout(checkout) {
226
+ if (!checkout)
227
+ return '- Checkout under test: not a git checkout with a commit, so rely on the brief\'s marker alone.';
228
+ const branch = checkout.branch ?? 'a detached HEAD';
229
+ return `- Checkout under test: ${branch} at ${checkout.head}. Read for you, so never run git.`;
230
+ }
231
+ export function buildLivePassPrompt(config) {
232
+ const { brief, driver, targets, workingDirectory, artifactDir, checkout, resumed } = config;
233
+ const procedure = driver === 'maestro' ? MAESTRO_PROCEDURE : PLAYWRIGHT_PROCEDURE;
234
+ const cheatSheet = driver === 'maestro' ? MAESTRO_CHEAT_SHEET : PLAYWRIGHT_CHEAT_SHEET;
235
+ return `# Live UI pass
236
+
237
+ You are walking a running application, capturing screenshots, looking at them yourself, and reporting what you saw as text. The caller never opens an image, so everything you see in a screenshot must become a sentence in your answer.
238
+ Your answer is ONLY the schema JSON. No prose before or after it, no markdown fences, no image data, no base64, no accessibility trees.
239
+
240
+ ## Environment
241
+ - Artifact directory (write here, and only here): ${artifactDir}
242
+ - Working directory: ${workingDirectory}. It is only where you run: read, list and glob nothing outside the artifact directory.
243
+ ${renderCheckout(checkout)}
244
+ - Never edit product code, never create or modify files outside the artifact directory, and never run a git command.
245
+
246
+ ## Scope
247
+ Everything you need is in this prompt, the brief, and what the app shows you.
248
+ - Never read product source, tests, docs, config, tool schemas or tool source. Never query a database. Never run a shell command except the capture command the procedure names and removing a stale file at a target's own "file:" path.
249
+ - Never read code to work out why something failed. Report what you saw.
250
+
251
+ ## User path
252
+ - The pass opens the app once, at its start URL (or by launching it on a device), as the brief names it.
253
+ - A target may begin at a link of its own only when the brief says the user arrives by that link, such as an invite, email or share link, and names where the link comes from. Open it as following that link would, and quote the brief's words naming the source in that frame's "link_source".
254
+ - Every other target is reached by taps from the previous screen: sign in through the sign-in form, tap through the navigation, and fill and submit forms. Any other URL or deep link in the brief is context, never a navigation target.
255
+ - A reload, only where the brief makes one part of the path, opens the URL the page is already on.
256
+ - No script that clicks, navigates, or sets storage, cookies or app state for you.
257
+
258
+ ## Dead ends
259
+ - When the path to a target breaks (a control is missing, a tap does nothing, a screen errors), stop that target and add it to "skipped" with the step where the path broke, such as "step 3: tapped Save, nothing happened". A dead end a user would hit is also a finding.
260
+ - When the brief lacks a fact you need (a label or testID, a seed account or id, which tab), add the target to "skipped" with "brief missing: <the fact>". Never search the code for it.
261
+
262
+ ## Procedure
263
+ ${procedure}
264
+
265
+ ${cheatSheet}
266
+
267
+ ## Targets
268
+ Capture exactly these, use these exact screen / state / viewport strings in your frames, and save each one to its own "file:" path — copied verbatim into that frame's "file". One file per target: a file that answers two targets is treated as no evidence at all.
269
+ ${renderTargets(targets, artifactDir, driver)}
270
+
271
+ ## Served-branch check first
272
+ Before any capture, confirm the running app shows the served-branch marker the brief describes. If it does not, or you cannot find it, stop, set verdict BLOCKED, and explain in served_branch_check. Never report on an app you cannot identify.
273
+ The checkout above is context. Set BLOCKED over it only when the marker names a branch or commit that contradicts it. A detached HEAD never contradicts a branch name, and a short commit hash matches the full one it begins.
274
+
275
+ ## Evidence discipline
276
+ - Open every PNG you saved and describe it in "observed". "observed" is what the image shows, not what you expected it to show.
277
+ - Judge each frame against the state the brief requested for it, using that target's "expected" as the checklist:
278
+ - Where a content state was requested, a spinner, blank page, skeleton, error, or the wrong screen is a FAIL.
279
+ - Where the brief intentionally exercises an error, empty, offline, permission-denied or other terminal state, the frame PASSES when it shows that state with everything "expected" names (for instance the reason text and the recovery affordance, when "expected" names them) and FAILS when any of that is missing. An intentional state that renders as "expected" describes it is never a finding by itself.
280
+ - Something "expected" does not ask for is at most a MINOR finding. It never makes the frame FAIL.
281
+ - Report position separately from presence: "the control exists but sits below the fold" is not "the control is missing".
282
+ - If a target is unreachable, delete any stale PNG already sitting at that frame's filename, add the target to "skipped" with the reason, and do not invent a frame for it.
283
+ - If you cannot open a PNG you saved, say so in "observed" and mark that frame FAIL.
284
+
285
+ ## Manifest
286
+ Write ${artifactDir}/${LIVE_PASS.MANIFEST_FILE} containing {"run_started": <ISO timestamp>, "driver": "${driver}", "frames": [...], "skipped": [...]} mirroring your answer.
287
+
288
+ ## Verdict rules
289
+ - PASS only when every requested target was captured and each frame matches the state the brief requested.
290
+ - FAIL when any frame is FAIL, any finding is BLOCKER or MAJOR, or anything regressed.
291
+ - BLOCKED when the environment stopped you: the app is not the branch under test, the driver would not start, or a tool call was denied.
292
+ ${resumed ? `\n${RESUMED_DELTA_SECTION}\n` : '\nThis is a fresh pass: return empty arrays for "resolved", "regressions" and "changes".\n'}
293
+ ## Brief
294
+ ${brief}`;
295
+ }
296
+ /**
297
+ * Realpath'd like the artifact dir, so a frame reported through a symlinked
298
+ * spelling of the same directory is not mistaken for an escape.
299
+ */
300
+ function frameAbsolutePath(file, artifactDir) {
301
+ return realpathOfExistingPrefix(path.isAbsolute(file) ? file : path.resolve(artifactDir, file));
302
+ }
303
+ function readManifestProblems(artifactDir, frameCount, runStartMs) {
304
+ const manifestPath = path.join(artifactDir, LIVE_PASS.MANIFEST_FILE);
305
+ let raw;
306
+ let writtenMs;
307
+ try {
308
+ raw = fs.readFileSync(manifestPath, 'utf-8');
309
+ writtenMs = fs.statSync(manifestPath).mtimeMs;
310
+ }
311
+ catch {
312
+ return [`${LIVE_PASS.MANIFEST_FILE} was not written`];
313
+ }
314
+ // A reused artifact directory can still hold an earlier run's manifest, which
315
+ // would pass a shape-and-count check while describing frames nobody captured.
316
+ if (writtenMs < runStartMs - LIVE_PASS.FRESHNESS_TOLERANCE_MS) {
317
+ return [`${LIVE_PASS.MANIFEST_FILE} predates this run: it is left over from an earlier pass`];
318
+ }
319
+ let parsed;
320
+ try {
321
+ parsed = JSON.parse(raw);
322
+ }
323
+ catch {
324
+ return [`${LIVE_PASS.MANIFEST_FILE} is not valid JSON`];
325
+ }
326
+ const manifestFrames = parsed?.frames;
327
+ if (!Array.isArray(manifestFrames)) {
328
+ return [`${LIVE_PASS.MANIFEST_FILE} has no frames array`];
329
+ }
330
+ if (manifestFrames.length !== frameCount) {
331
+ return [`${LIVE_PASS.MANIFEST_FILE} lists ${manifestFrames.length} frames but the answer reports ${frameCount}`];
332
+ }
333
+ return [];
334
+ }
335
+ function listCaptures(artifactDir) {
336
+ try {
337
+ return fs.readdirSync(artifactDir)
338
+ .filter(name => IMAGE_FILE.test(name))
339
+ .map(name => path.join(artifactDir, name));
340
+ }
341
+ catch {
342
+ return [];
343
+ }
344
+ }
345
+ /** Check every reported frame against the disk. Never throws. */
346
+ export function validateFrames(result, artifactDir, runStartMs, targets = []) {
347
+ const prescribed = prescribedFrameNames(targets);
348
+ const claimedBy = new Set();
349
+ const notes = [];
350
+ const checks = result.frames.map(frame => {
351
+ const absFile = frameAbsolutePath(frame.file, artifactDir);
352
+ const expectedName = prescribed.get(targetKey(frame));
353
+ // A second frame pointing at the same file is one screenshot answering two
354
+ // targets — no evidence for either, however good the picture is.
355
+ const fileStatus = checkArtifactFile(absFile, artifactDir, runStartMs);
356
+ const status = claimedBy.has(absFile) ? 'DUPLICATE'
357
+ : !IMAGE_FILE.test(absFile) ? 'NOT_IMAGE'
358
+ : fileStatus !== 'OK' ? fileStatus
359
+ : hasImageSignature(absFile) ? 'OK' : 'NOT_IMAGE';
360
+ claimedBy.add(absFile);
361
+ const misnamed = Boolean(expectedName) && path.basename(absFile) !== expectedName;
362
+ if (misnamed) {
363
+ notes.push(`MISNAMED: ${absFile} (${frame.screen} / ${frame.state}) — expected ${expectedName}`);
364
+ }
365
+ return { file: absFile, screen: frame.screen, state: frame.state, status, misnamed };
366
+ });
367
+ const problems = [
368
+ ...checks.filter(check => check.status !== 'OK').map(check => check.status === 'DUPLICATE'
369
+ ? `DUPLICATE: reused evidence file ${check.file} (${check.screen} / ${check.state})`
370
+ : `${check.status}: ${check.file} (${check.screen} / ${check.state})`),
371
+ ...readManifestProblems(artifactDir, result.frames.length, runStartMs),
372
+ ];
373
+ return {
374
+ checks,
375
+ okFiles: checks.filter(check => check.status === 'OK').map(check => check.file),
376
+ problems,
377
+ notes,
378
+ unreportedCaptures: listCaptures(artifactDir).filter(file => !claimedBy.has(file)),
379
+ };
380
+ }
381
+ /** Match each requested target to a reported frame, case- and whitespace-insensitively. */
382
+ export function checkCoverage(targets, result) {
383
+ const frameKeys = new Map();
384
+ for (const frame of result.frames) {
385
+ frameKeys.set(targetKey(frame), frame.file);
386
+ }
387
+ const uncovered = [];
388
+ const skipped = [];
389
+ const matchedKeys = new Set();
390
+ for (const target of targets) {
391
+ const key = targetKey(target);
392
+ if (frameKeys.has(key)) {
393
+ matchedKeys.add(key);
394
+ continue;
395
+ }
396
+ const skip = result.skipped.find(entry => {
397
+ const haystack = normalize(entry.target);
398
+ return haystack.includes(normalize(target.screen)) && haystack.includes(normalize(target.state));
399
+ });
400
+ if (skip) {
401
+ skipped.push({ target, reason: skip.reason });
402
+ }
403
+ else {
404
+ uncovered.push(target);
405
+ }
406
+ }
407
+ const extraFrames = result.frames
408
+ .filter(frame => !matchedKeys.has(targetKey(frame)))
409
+ .map(frame => frame.file);
410
+ return { uncovered, skipped, extraFrames };
411
+ }
412
+ /** Gemini's own verdict is advisory; this is the one the caller acts on. */
413
+ export function deriveVerdict(input) {
414
+ const { result, validation, coverage, deniedActions } = input;
415
+ const reasons = [];
416
+ if (deniedActions)
417
+ reasons.push(`agy denied required actions: ${deniedActions}`);
418
+ if (result.verdict === 'BLOCKED')
419
+ reasons.push('Gemini reported BLOCKED');
420
+ if (reasons.length > 0)
421
+ return { verdict: 'BLOCKED', reported: result.verdict, reasons };
422
+ for (const frame of result.frames.filter(frame => frame.verdict === 'FAIL')) {
423
+ reasons.push(`frame FAIL: ${frame.screen} / ${frame.state}`);
424
+ }
425
+ reasons.push(...validation.problems);
426
+ for (const target of coverage.uncovered) {
427
+ reasons.push(`uncovered target: ${target.screen} / ${target.state} / ${target.viewport}`);
428
+ }
429
+ for (const entry of coverage.skipped) {
430
+ reasons.push(`skipped target: ${entry.target.screen} / ${entry.target.state} (${entry.reason})`);
431
+ }
432
+ for (const regression of result.regressions) {
433
+ reasons.push(`regression: ${regression.screen} — ${regression.description}`);
434
+ }
435
+ for (const finding of result.findings.filter(f => f.severity === 'BLOCKER' || f.severity === 'MAJOR')) {
436
+ reasons.push(`${finding.severity} finding: ${finding.screen} — ${finding.description}`);
437
+ }
438
+ return {
439
+ verdict: reasons.length > 0 ? 'FAIL' : 'PASS',
440
+ reported: result.verdict,
441
+ reasons,
442
+ };
443
+ }
444
+ function shellQuote(value) {
445
+ return `'${value.replace(/'/g, `'\\''`)}'`;
446
+ }
447
+ export async function runPublishCommand(command, okFiles, cwd) {
448
+ if (!command?.trim()) {
449
+ return { ran: false, stdout: '', stderr: '', exitCode: null };
450
+ }
451
+ if (okFiles.length === 0) {
452
+ return { ran: false, stdout: '', stderr: '', exitCode: null, skippedReason: 'no frames validated OK' };
453
+ }
454
+ const resolved = command.split(FILES_PLACEHOLDER).join(okFiles.map(shellQuote).join(' '));
455
+ try {
456
+ const running = execFileAsync('/bin/sh', ['-c', resolved], {
457
+ cwd,
458
+ maxBuffer: PUBLISH_MAX_BUFFER,
459
+ timeout: PUBLISH_TIMEOUT_MS,
460
+ });
461
+ // Close stdin so a command that reads it (an upload prompting for input)
462
+ // fails fast instead of hanging the MCP call after a finished pass.
463
+ running.child.stdin?.end();
464
+ const { stdout, stderr } = await running;
465
+ return { ran: true, command: resolved, stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 };
466
+ }
467
+ catch (error) {
468
+ const failure = error;
469
+ return {
470
+ ran: true,
471
+ command: resolved,
472
+ stdout: (failure.stdout ?? '').trim(),
473
+ stderr: (failure.stderr ?? failure.message ?? '').trim(),
474
+ exitCode: typeof failure.code === 'number' ? failure.code : null,
475
+ };
476
+ }
477
+ }
478
+ /** Each web target's layout file, read from its prescribed path. Never throws. */
479
+ export function readLayoutChecks(targets, artifactDir, runStartMs) {
480
+ const files = prescribedTargetFiles(targets, artifactDir);
481
+ return targets.map(target => ({
482
+ target,
483
+ check: readLayoutCheck(files.get(targetKey(target)).layout, artifactDir, runStartMs, parseViewport(target.viewport)?.width),
484
+ }));
485
+ }
486
+ export function formatLayoutSection(checks) {
487
+ const lines = checks.map(({ target, check }) => {
488
+ const label = `${target.screen} / ${target.state} / ${target.viewport}`;
489
+ if (!check.report)
490
+ return `- ${label}: not run (${check.status}: ${check.file} ${check.problem})`;
491
+ const { entries, clamped, found } = check.report;
492
+ if (entries.length === 0 && clamped.length === 0)
493
+ return `- ${label}: clean`;
494
+ const listed = found > entries.length ? ` (${found} found, first ${entries.length} listed)` : '';
495
+ const polluted = entries.some(entry => entry.kind === 'dev-overlay')
496
+ ? ' (a framework dev overlay was on screen: this frame is polluted)'
497
+ : '';
498
+ return [
499
+ `- ${label}: ${found} ${found === 1 ? 'entry' : 'entries'}${listed}${polluted}`,
500
+ ...entries.map(entry => ` - ${formatLayoutEntry(entry)}`),
501
+ ...clamped.map(entry => ` - capped (line clamp / ellipsis): ${formatLayoutEntry(entry)}`),
502
+ ].join('\n');
503
+ });
504
+ return section('Layout checks (measured in the page)', lines.join('\n'));
505
+ }
506
+ export function formatLivePassReport(input) {
507
+ const { envelope, result, decision, validation, coverage, driver, model, artifactDir } = input;
508
+ const statusById = new Map(validation.checks.map(check => [check.file, check.status]));
509
+ const headline = decision.verdict === decision.reported
510
+ ? `# Live pass: ${decision.verdict}`
511
+ : `# Live pass: ${decision.verdict} (Gemini reported ${decision.reported})`;
512
+ const parts = [];
513
+ if (envelope.conversation_id)
514
+ parts.push(formatConversationLine(envelope.conversation_id));
515
+ parts.push(headline);
516
+ parts.push(`driver: ${driver} · model: ${model} · artifacts: ${artifactDir}`);
517
+ parts.push(formatAgyUsageLine(envelope));
518
+ parts.push(formatModelSteps(input.modelSteps, result.frames.length));
519
+ parts.push(section('Why', bulletList(decision.reasons)));
520
+ parts.push(section('Served-branch check', result.served_branch_check || '- not reported'));
521
+ parts.push(section('Summary', result.summary || '- not reported'));
522
+ parts.push(section('Frames', bulletList(result.frames.map(frame => {
523
+ const abs = frameAbsolutePath(frame.file, artifactDir);
524
+ const arrival = frame.link_source ? `\n arrived by link: ${frame.link_source}` : '';
525
+ return `${frame.verdict} [${statusById.get(abs) ?? 'UNKNOWN'}] ${frame.screen} / ${frame.state} / ${frame.viewport}\n` +
526
+ ` file: ${abs}${arrival}\n expected: ${frame.expected}\n observed: ${frame.observed}`;
527
+ }))));
528
+ const renderFindings = (items) => bulletList(items.map(item => `${item.severity} ${item.screen}: ${item.description}${item.evidence_file ? ` (${item.evidence_file})` : ''}`));
529
+ const renderNotes = (items) => bulletList(items.map(item => `${item.screen}: ${item.description}${item.evidence_file ? ` (${item.evidence_file})` : ''}`));
530
+ parts.push(section('Findings', renderFindings(result.findings)));
531
+ parts.push(section('Regressions', renderFindings(result.regressions)));
532
+ parts.push(section('Resolved', renderNotes(result.resolved)));
533
+ parts.push(section('Changes', renderNotes(result.changes)));
534
+ parts.push(section('Skipped', bulletList([
535
+ ...result.skipped.map(entry => `${entry.target}: ${entry.reason}`),
536
+ ...coverage.uncovered.map(target => `${target.screen} / ${target.state} / ${target.viewport}: no frame and no skip reason`),
537
+ ])));
538
+ const validationLines = [...validation.problems, ...validation.notes];
539
+ if (validation.unreportedCaptures.length > 0) {
540
+ validationLines.push(`captures not reported as frames: ${validation.unreportedCaptures.join(', ')}`);
541
+ }
542
+ if (coverage.extraFrames.length > 0) {
543
+ validationLines.push(`frames matching no requested target: ${coverage.extraFrames.join(', ')}`);
544
+ }
545
+ parts.push(section('Validation', bulletList(validationLines)));
546
+ if (input.layoutChecks)
547
+ parts.push(formatLayoutSection(input.layoutChecks));
548
+ if (input.deniedActions) {
549
+ parts.push(section('Denied actions', `- ${input.deniedActions}`));
550
+ }
551
+ const workingTreeSection = formatWorkingTreeSection(input.workingTree);
552
+ if (workingTreeSection)
553
+ parts.push(workingTreeSection);
554
+ const { publish } = input;
555
+ if (publish.ran) {
556
+ parts.push(section('PR markdown', publish.exitCode === 0
557
+ ? publish.stdout || '- publish command produced no output'
558
+ : `- publish command exited ${publish.exitCode ?? 'unknown'}\n- ${publish.stderr || 'no stderr'}`));
559
+ }
560
+ else if (publish.skippedReason) {
561
+ parts.push(section('PR markdown', `- publish command not run: ${publish.skippedReason}`));
562
+ }
563
+ return parts.join('\n\n');
564
+ }
565
+ // ------------------------------------------------------------------ execute
566
+ function resolveArtifactDir(workingDirectory, requested) {
567
+ // The default candidate goes through the same resolve-and-contain check as an
568
+ // explicit one: a `.live-pass` symlink pointing outside the worktree would
569
+ // otherwise let the default escape the boundary the tool advertises.
570
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
571
+ const candidate = requested ?? path.join(workingDirectory, LIVE_PASS.DEFAULT_ARTIFACT_SEGMENT, stamp);
572
+ const resolved = realpathOfExistingPrefix(candidate);
573
+ const strictlyInside = path.isAbsolute(candidate) &&
574
+ isWithin(workingDirectory, resolved) &&
575
+ resolved !== workingDirectory;
576
+ if (!strictlyInside) {
577
+ throw new Error(`${ERROR_MESSAGES.LIVE_PASS_ARTIFACT_DIR}. Received '${candidate}'.`);
578
+ }
579
+ return resolved;
580
+ }
581
+ export const livePassTool = {
582
+ name: "live-pass",
583
+ description: "Delegate a live UI pass to Gemini: it drives its own Playwright or Maestro MCP server, captures screenshots into a worktree-local artifact directory, looks at them itself, and returns a text verdict with per-frame observations. No image ever reaches the caller's context.",
584
+ zodSchema: livePassArgsSchema,
585
+ prompt: {
586
+ description: "Walk a running app with Gemini driving Playwright or Maestro, capture the requested screen/state/viewport frames, and return a PASS/FAIL/BLOCKED verdict with per-frame observations, findings, and optional PR markdown.",
587
+ },
588
+ category: 'gemini',
589
+ annotations: {
590
+ title: "Live UI Pass",
591
+ readOnlyHint: false,
592
+ openWorldHint: true,
593
+ },
594
+ execute: async (args, onProgress, signal) => {
595
+ const input = livePassArgsSchema.parse(args);
596
+ const workingDirectory = resolveWorkingDirectory(input.workingDirectory);
597
+ const artifactDir = resolveArtifactDir(workingDirectory, input.artifactDir);
598
+ fs.mkdirSync(artifactDir, { recursive: true });
599
+ if (input.driver === 'playwright')
600
+ writeCaptureScripts(input.targets, artifactDir);
601
+ const runStartMs = Date.now();
602
+ const before = await snapshotWorkingTree(workingDirectory);
603
+ const checkout = await readCheckout(workingDirectory);
604
+ const afterWorkingTree = async () => diffWorkingTree(before, await snapshotWorkingTree(workingDirectory), { ignoreDirectory: artifactDir });
605
+ let envelope;
606
+ try {
607
+ envelope = await executeAgyJson(buildLivePassPrompt({
608
+ brief: input.brief,
609
+ driver: input.driver,
610
+ targets: input.targets,
611
+ workingDirectory,
612
+ artifactDir,
613
+ checkout,
614
+ resumed: Boolean(input.conversationId),
615
+ }), {
616
+ model: input.model,
617
+ yolo: input.yolo,
618
+ printTimeout: input.printTimeout,
619
+ conversationId: input.conversationId,
620
+ includeDirectories: [workingDirectory, artifactDir],
621
+ cwd: workingDirectory,
622
+ jsonSchema: LIVE_PASS_RESULT_SCHEMA_TEXT,
623
+ noCache: true,
624
+ signal,
625
+ }, onProgress);
626
+ }
627
+ catch (error) {
628
+ // A timeout, a non-zero exit or an ERROR envelope can all land after
629
+ // Gemini has already used its shell: the warning must survive the failure.
630
+ failWithWorkingTree(error, await afterWorkingTree());
631
+ }
632
+ // Snapshot before branching: an off-schema answer must still report edits
633
+ // Gemini made with the shell this pass handed it.
634
+ const workingTree = await afterWorkingTree();
635
+ const parsed = livePassResultSchema.safeParse(envelope.structured_output);
636
+ if (!parsed.success) {
637
+ return [
638
+ renderUnparsableEnvelope(envelope, '# Live pass: BLOCKED', formatZodIssues(parsed.error), `artifacts: ${artifactDir}`),
639
+ formatWorkingTreeSection(workingTree),
640
+ ].filter(Boolean).join('\n\n');
641
+ }
642
+ const result = parsed.data;
643
+ const validation = validateFrames(result, artifactDir, runStartMs, input.targets);
644
+ const coverage = checkCoverage(input.targets, result);
645
+ const deniedActions = deniedActionsSummary(envelope);
646
+ const decision = deriveVerdict({ result, validation, coverage, deniedActions });
647
+ // A BLOCKED pass proves nothing, so its frames are never published.
648
+ const publish = decision.verdict === 'BLOCKED'
649
+ ? { ran: false, stdout: '', stderr: '', exitCode: null, skippedReason: 'pass was BLOCKED' }
650
+ : await runPublishCommand(input.publishCommand, validation.okFiles, workingDirectory);
651
+ return formatLivePassReport({
652
+ envelope,
653
+ result,
654
+ decision,
655
+ validation,
656
+ coverage,
657
+ driver: input.driver,
658
+ model: input.model,
659
+ artifactDir,
660
+ deniedActions,
661
+ workingTree,
662
+ publish,
663
+ modelSteps: envelope.conversation_id ? await countAgyModelSteps(envelope.conversation_id) : null,
664
+ layoutChecks: input.driver === 'playwright'
665
+ ? readLayoutChecks(input.targets, artifactDir, runStartMs)
666
+ : undefined,
667
+ });
668
+ },
669
+ };
670
+ //# sourceMappingURL=live-pass.tool.js.map