diff --git a/AGENTS.md b/AGENTS.md index f8e7f32724..ab7e83964f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ installs six discoverable skills: the five judgment dispatchers plus make-pdf. | Skill | Primary responsibility | |---|---| | `/plan` | Product framing, CEO scope, engineering architecture, DX, autoplan, executable specs, and planning preferences. | -| `/qa` | Report-only or fix-and-verify web QA, physical-iOS QA, DX journeys, performance, and canaries. | +| `/qa` | Report-only or fix-and-verify web QA, physical-iOS QA, DX journeys, performance, canaries, and recorded agent walkthroughs. | | `/debug` | Root-cause investigation, physical-iOS fixes, and internal safety controls. | | `/review` | Diff, security, repository-health, and independent outside-voice review. | | `/ship` | PR preparation, landing/deployment, queue inspection, release docs, upgrade, and internal iOS release operations. | @@ -39,7 +39,7 @@ Its question order, pressure, smart skips, STOP/approval gates, evidence, artifacts, mutation boundary, exit behavior, and voice are binding. Preserve report-only versus fix behavior. List skipped primary modules and why. -The exhaustive 55-command compatibility map is in +The exhaustive 56-command compatibility map is in [`docs/gstack-2/SKILL-MIGRATION.md`](docs/gstack-2/SKILL-MIGRATION.md). Old names are opt-in routing aliases and must print their replacement invocation; they contain no copied judgment. Representative mappings: @@ -50,6 +50,7 @@ they contain no copied judgment. Representative mappings: | `/plan-ceo-review` | `/plan --mode ceo` | | `/plan-eng-review` | `/plan --mode eng` | | `/qa-only` | `/qa --mode report` | +| `/recording` | `/qa --mode Report --module recording` | | `/investigate` | `/debug --mode investigate` | | `/cso` | `/review --mode security` | | `/land-and-deploy` | `/ship --mode land` | diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 0e8383e6f8..84061c6cff 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -46,6 +46,7 @@ export const META_COMMANDS = new Set([ 'connect', 'disconnect', 'focus', 'inbox', 'watch', + 'record', 'state', 'frame', 'ux-audit', @@ -175,6 +176,7 @@ export const COMMAND_DESCRIPTIONS: Record' }, // Frame @@ -232,6 +234,7 @@ export function canonicalizeCommand(cmd: string): string { */ export const NEW_IN_VERSION: Record = { 'load-html': '0.19.0.0', + 'record': '2.0.0.0', }; /** diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index 8bc3b4c1a5..f26e47d918 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -1171,6 +1171,11 @@ export async function handleMetaCommand( return await handleMemoryCommand(args, bm); } + case 'record': { + const { handleRecordCommand } = await import('./screencast'); + return await handleRecordCommand(args, bm); + } + default: throw new Error(`Unknown meta command: ${command}`); } diff --git a/browse/src/screencast-encode.ts b/browse/src/screencast-encode.ts new file mode 100644 index 0000000000..cbbee39ace --- /dev/null +++ b/browse/src/screencast-encode.ts @@ -0,0 +1,176 @@ +/** + * Encode a JPEG frame directory into a playable recording, and open it. + * + * ffmpeg is optional. When it is missing or fails, we write a self-contained + * HTML player that steps through the frames at the captured fps so the user + * still gets something they can open. No Playwright, no CDP. + */ + +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { TEMP_DIR } from './platform'; + +export const FRAME_PATTERN_JPG = 'frame_%06d.jpg'; +export const FRAME_PATTERN_PNG = 'frame_%06d.png'; + +export interface EncodeResult { + artifactPath: string; + kind: 'mp4' | 'webm' | 'html'; + frameCount: number; + ffmpeg: boolean; +} + +function listFrames(framesDir: string): { files: string[]; pattern: string } { + if (!fs.existsSync(framesDir)) return { files: [], pattern: FRAME_PATTERN_JPG }; + const names = fs.readdirSync(framesDir); + const jpgs = names.filter((f) => /^frame_\d{6}\.jpe?g$/i.test(f)).sort(); + if (jpgs.length) return { files: jpgs, pattern: FRAME_PATTERN_JPG }; + const pngs = names.filter((f) => /^frame_\d{6}\.png$/i.test(f)).sort(); + return { files: pngs, pattern: FRAME_PATTERN_PNG }; +} + +/** True when `ffmpeg` is on PATH. Pure probe — never throws. */ +export function ffmpegAvailable(): boolean { + try { + const r = spawnSync('ffmpeg', ['-version'], { + stdio: 'ignore', + timeout: 4000, + windowsHide: true, + }); + return r.status === 0; + } catch { + return false; + } +} + +function writeHtmlPlayer(framesDir: string, frames: string[], fps: number, dest: string): void { + const safeFps = Number.isFinite(fps) && fps > 0 ? fps : 8; + const relFrames = frames.map((f) => path.basename(f)); + const html = ` + + + + gstack recording + + + +
+ recording frame +
+ + +
+
+ + + +`; + fs.writeFileSync(dest, html); +} + +function runFfmpeg(framesDir: string, fps: number, outputPath: string, pattern: string): boolean { + const input = path.join(framesDir, pattern); + const attempts: string[][] = [ + ['-y', '-hide_banner', '-loglevel', 'error', '-framerate', String(fps), '-i', input, + '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', outputPath], + ['-y', '-hide_banner', '-loglevel', 'error', '-framerate', String(fps), '-i', input, + '-c:v', 'libvpx-vp9', '-pix_fmt', 'yuv420p', outputPath], + ]; + for (const args of attempts) { + try { + const r = spawnSync('ffmpeg', args, { + stdio: 'ignore', + timeout: 120_000, + windowsHide: true, + }); + if (r.status === 0 && fs.existsSync(outputPath) && fs.statSync(outputPath).size > 0) { + return true; + } + } catch { + // try next encoder + } + } + return false; +} + +/** + * Turn numbered JPEG frames into a playable artifact next to `outputPath`. + * Prefers mp4 via ffmpeg; falls back to an HTML player in the frame directory. + */ +export function encodeFrameDirectory( + framesDir: string, + outputPath: string, + fps: number, +): EncodeResult { + const { files: frames, pattern } = listFrames(framesDir); + if (frames.length === 0) { + throw new Error(`No recording frames in ${framesDir}`); + } + + const ext = path.extname(outputPath).toLowerCase(); + const videoPath = ext === '.webm' || ext === '.mp4' ? outputPath : outputPath.replace(/\.[^.]+$/, '') + '.mp4'; + + if (ffmpegAvailable() && runFfmpeg(framesDir, fps, videoPath, pattern)) { + const kind = path.extname(videoPath).toLowerCase() === '.webm' ? 'webm' : 'mp4'; + return { artifactPath: videoPath, kind, frameCount: frames.length, ffmpeg: true }; + } + + const htmlPath = path.join(framesDir, 'player.html'); + writeHtmlPlayer(framesDir, frames, fps, htmlPath); + return { artifactPath: htmlPath, kind: 'html', frameCount: frames.length, ffmpeg: false }; +} + +/** Open a file with the platform viewer. Best-effort; never throws. */ +export function openArtifact(filePath: string): boolean { + const resolved = path.resolve(filePath); + if (!fs.existsSync(resolved)) return false; + const cmd = + process.platform === 'darwin' ? ['open', resolved] + : process.platform === 'win32' ? ['cmd', '/c', 'start', '', resolved] + : ['xdg-open', resolved]; + try { + const r = spawnSync(cmd[0], cmd.slice(1), { + stdio: 'ignore', + timeout: 8000, + windowsHide: true, + }); + return r.status === 0; + } catch { + return false; + } +} + +export function defaultRecordingPath(stamp = Date.now()): string { + return path.join(TEMP_DIR, `gstack-recording-${stamp}.mp4`); +} diff --git a/browse/src/screencast.ts b/browse/src/screencast.ts new file mode 100644 index 0000000000..2da72294cc --- /dev/null +++ b/browse/src/screencast.ts @@ -0,0 +1,274 @@ +/** + * `$B record start|stop|status|open` — capture a Chromium screencast of the + * agent driving the page, encode it, and optionally open it for the user. + * + * Uses CDP Page.startScreencast through the cdp-bridge helpers (never a + * raw newCDPSession). Frames go to a temp directory as JPEGs; encode is + * ffmpeg-if-present, HTML player otherwise (screencast-encode.ts). + * + * Not on the pair-agent tunnel allowlist: a continuous capture is a + * larger exfil surface than a single screenshot. + */ + +import type { Page } from 'playwright'; +import type { BrowserManager } from './browser-manager'; +import { getOrCreateCdpSession } from './cdp-bridge'; +import { mkdirSecure } from './file-permissions'; +import { validateOutputPath } from './path-security'; +import { TEMP_DIR } from './platform'; +import { + defaultRecordingPath, + encodeFrameDirectory, + openArtifact, + type EncodeResult, +} from './screencast-encode'; +import * as fs from 'fs'; +import * as path from 'path'; + +const DEFAULT_FPS = 8; +const DEFAULT_QUALITY = 60; +const MAX_FRAMES = 7200; // 15 min at 8 fps +const MAX_DURATION_MS = 15 * 60 * 1000; +const MIN_FRAME_GAP_MS = 40; // drop CDP frames faster than ~25 fps + +const sessionCache: WeakMap = new WeakMap(); + +export interface RecordingStatus { + active: boolean; + outputPath?: string; + framesDir?: string; + frameCount?: number; + elapsedMs?: number; + fps?: number; + lastArtifact?: string; +} + +interface ActiveRecording { + page: Page; + session: any; + framesDir: string; + outputPath: string; + fps: number; + startedAt: number; + frameCount: number; + lastKeptAt: number; + onFrame: (params: { data: string; sessionId: number }) => void; +} + +let active: ActiveRecording | null = null; +let lastArtifact: string | null = null; + +export function getLastArtifact(): string | null { + return lastArtifact; +} + +export function recordingStatus(): RecordingStatus { + if (!active) { + return { active: false, lastArtifact: lastArtifact ?? undefined }; + } + return { + active: true, + outputPath: active.outputPath, + framesDir: active.framesDir, + frameCount: active.frameCount, + elapsedMs: Date.now() - active.startedAt, + fps: active.fps, + lastArtifact: lastArtifact ?? undefined, + }; +} + +function parseStartArgs(args: string[]): { outputPath: string; fps: number; quality: number } { + let outputPath = defaultRecordingPath(); + let fps = DEFAULT_FPS; + let quality = DEFAULT_QUALITY; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--fps') { + const n = Number(args[++i]); + if (!Number.isFinite(n) || n < 1 || n > 30) throw new Error('record start: --fps must be 1-30'); + fps = Math.round(n); + } else if (a === '--quality') { + const n = Number(args[++i]); + if (!Number.isFinite(n) || n < 1 || n > 100) throw new Error('record start: --quality must be 1-100'); + quality = Math.round(n); + } else if (a.startsWith('--')) { + throw new Error(`Unknown record start flag: ${a}`); + } else { + outputPath = a; + } + } + return { outputPath, fps, quality }; +} + +async function ackFrame(session: any, sessionId: number): Promise { + try { + await session.send('Page.screencastFrameAck', { sessionId }); + } catch { + // Page may have navigated or the session detached. Drop the ack. + } +} + +export async function startRecording( + bm: BrowserManager, + args: string[], +): Promise { + if (active) { + throw new Error(`Already recording to ${active.outputPath}. Run: record stop`); + } + + const page = bm.getPage(); + const { outputPath, fps, quality } = parseStartArgs(args); + validateOutputPath(outputPath); + + const stamp = Date.now(); + const framesDir = path.join(TEMP_DIR, `gstack-recording-frames-${stamp}`); + mkdirSecure(framesDir); + const outDir = path.dirname(path.resolve(outputPath)); + if (!fs.existsSync(outDir)) mkdirSecure(outDir); + + const session = await getOrCreateCdpSession(page, sessionCache); + const rec: ActiveRecording = { + page, + session, + framesDir, + outputPath, + fps, + startedAt: stamp, + frameCount: 0, + lastKeptAt: 0, + onFrame: () => {}, + }; + + rec.onFrame = (params) => { + void (async () => { + await ackFrame(session, params.sessionId); + if (active !== rec) return; + const now = Date.now(); + if (now - rec.startedAt > MAX_DURATION_MS) { + void stopRecording({ open: false }).catch(() => {}); + return; + } + const minGap = Math.max(MIN_FRAME_GAP_MS, Math.round(1000 / rec.fps)); + if (rec.lastKeptAt && now - rec.lastKeptAt < minGap) return; + if (rec.frameCount >= MAX_FRAMES) { + void stopRecording({ open: false }).catch(() => {}); + return; + } + rec.lastKeptAt = now; + rec.frameCount += 1; + const file = path.join(rec.framesDir, `frame_${String(rec.frameCount).padStart(6, '0')}.jpg`); + try { + fs.writeFileSync(file, Buffer.from(params.data, 'base64')); + } catch { + rec.frameCount -= 1; + } + })(); + }; + + session.on('Page.screencastFrame', rec.onFrame); + await session.send('Page.startScreencast', { + format: 'jpeg', + quality, + everyNthFrame: 1, + }); + active = rec; + + return `Recording started → ${outputPath}\nFrames: ${framesDir}\nStop with: record stop [--open]`; +} + +export async function stopRecording(opts: { open?: boolean; keepFrames?: boolean } = {}): Promise { + const rec = active; + if (!rec) { + throw new Error(lastArtifact + ? `Not recording. Last recording: ${lastArtifact}` + : 'Not recording. Start with: record start [path]'); + } + active = null; + + try { + if (typeof rec.session.off === 'function') rec.session.off('Page.screencastFrame', rec.onFrame); + else if (typeof rec.session.removeListener === 'function') rec.session.removeListener('Page.screencastFrame', rec.onFrame); + await rec.session.send('Page.stopScreencast'); + } catch { + // Session may already be dead; still encode whatever frames we have. + } + + if (rec.frameCount === 0) { + // Static pages sometimes emit no screencast frames. Grab one screenshot + // so stop still produces an artifact. + try { + const buf = await rec.page.screenshot({ type: 'jpeg', quality: 70 }); + const file = path.join(rec.framesDir, 'frame_000001.jpg'); + fs.writeFileSync(file, buf); + rec.frameCount = 1; + } catch (err: any) { + throw new Error(`Recording captured 0 frames and screenshot fallback failed: ${err?.message ?? err}`); + } + } + + const encoded: EncodeResult = encodeFrameDirectory(rec.framesDir, rec.outputPath, rec.fps); + lastArtifact = encoded.artifactPath; + + if (!opts.keepFrames && encoded.kind !== 'html') { + try { + fs.rmSync(rec.framesDir, { recursive: true, force: true }); + } catch { + // leave frames if cleanup fails + } + } + + const elapsed = ((Date.now() - rec.startedAt) / 1000).toFixed(1); + const opened = opts.open ? openArtifact(encoded.artifactPath) : false; + const lines = [ + `RECORDING: ${encoded.artifactPath}`, + `kind=${encoded.kind} frames=${encoded.frameCount} elapsed=${elapsed}s ffmpeg=${encoded.ffmpeg}`, + ]; + if (opts.open) lines.push(opened ? 'Opened in the local viewer.' : 'Could not open the viewer; open the RECORDING path yourself.'); + return lines.join('\n'); +} + +export async function flushRecordingOnShutdown(): Promise { + if (!active) return; + try { + await stopRecording({ open: false }); + } catch { + active = null; + } +} + +export async function handleRecordCommand(args: string[], bm: BrowserManager): Promise { + const action = args[0]; + const rest = args.slice(1); + if (!action || action === 'help' || action === '--help') { + throw new Error('Usage: record start [path] [--fps N] [--quality Q] | record stop [--open] [--keep-frames] | record status | record open [path]'); + } + + if (action === 'start') { + return startRecording(bm, rest); + } + if (action === 'stop') { + const open = rest.includes('--open'); + const keepFrames = rest.includes('--keep-frames'); + const unknown = rest.filter((a) => a.startsWith('--') && a !== '--open' && a !== '--keep-frames'); + if (unknown.length) throw new Error(`Unknown record stop flag: ${unknown[0]}`); + return stopRecording({ open, keepFrames }); + } + if (action === 'status') { + return JSON.stringify(recordingStatus(), null, 2); + } + if (action === 'open') { + const target = rest[0] || lastArtifact; + if (!target) throw new Error('No recording to open. Pass a path or run record stop --open.'); + validateOutputPath(target); + const ok = openArtifact(target); + return ok ? `Opened: ${target}` : `Could not open: ${target}`; + } + + throw new Error(`Unknown record action: ${action}. Use start, stop, status, or open.`); +} + +/** Test-only: drop in-memory recording state. Does not delete files. */ +export function __resetRecordingForTests(): void { + active = null; + lastArtifact = null; +} diff --git a/browse/src/server.ts b/browse/src/server.ts index b4b32ef54d..1711fd1c49 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -1411,6 +1411,12 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { safeUnlinkQuiet(shutdownStateFile); console.log('[browse] Shutting down...'); + try { + const { flushRecordingOnShutdown } = await import('./screencast'); + await flushRecordingOnShutdown(); + } catch (err: any) { + console.warn('[browse] Failed to flush recording:', err?.message ?? err); + } try { detachSession(); } catch (err: any) { console.warn('[browse] Failed to detach CDP session:', err.message); } diff --git a/browse/src/token-registry.ts b/browse/src/token-registry.ts index 161b26b6d5..b6eee0e60c 100644 --- a/browse/src/token-registry.ts +++ b/browse/src/token-registry.ts @@ -39,7 +39,7 @@ import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands'; export const SCOPE_READ = new Set([ 'snapshot', 'text', 'html', 'links', 'forms', 'accessibility', 'console', 'network', 'perf', 'dialog', 'is', 'inspect', - 'url', 'tabs', 'status', 'screenshot', 'pdf', 'css', 'attrs', + 'url', 'tabs', 'status', 'screenshot', 'pdf', 'record', 'css', 'attrs', 'media', 'data', ]); diff --git a/browse/test/dual-listener.test.ts b/browse/test/dual-listener.test.ts index 2237461f77..5dbb84fe0b 100644 --- a/browse/test/dual-listener.test.ts +++ b/browse/test/dual-listener.test.ts @@ -110,6 +110,7 @@ describe('Tunnel command allowlist', () => { 'restart', 'stop', 'tunnel-start', 'tunnel-stop', 'token-mint', 'token-revoke', 'cookie-picker', 'cookie-import', 'inspector-pick', 'pair', 'unpair', 'cookies', 'setup', + 'record', ]; for (const c of forbidden) { expect(cmds.has(c)).toBe(false); diff --git a/browse/test/screencast.test.ts b/browse/test/screencast.test.ts new file mode 100644 index 0000000000..35791481b7 --- /dev/null +++ b/browse/test/screencast.test.ts @@ -0,0 +1,156 @@ +/** + * `$B record` + JPEG/PNG frame encoding. + * + * Encode tests never launch a browser. The integration block launches one + * Chromium like snapshot.test.ts and asserts start/stop produce an artifact. + */ + +import { describe, test, expect, beforeAll, afterAll, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { startTestServer } from './test-server'; +import { BrowserManager } from '../src/browser-manager'; +import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands'; +import { handleMetaCommand } from '../src/meta-commands'; +import { + encodeFrameDirectory, + ffmpegAvailable, +} from '../src/screencast-encode'; +import { __resetRecordingForTests, recordingStatus } from '../src/screencast'; +import { META_COMMANDS, COMMAND_DESCRIPTIONS } from '../src/commands'; +import { SCOPE_READ } from '../src/token-registry'; + +const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) => + _handleWriteCommand(cmd, args, b.getActiveSession(), b); + +function tmpDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +describe('record command registry', () => { + test('record is a meta command with a description', () => { + expect(META_COMMANDS.has('record')).toBe(true); + expect(COMMAND_DESCRIPTIONS.record?.usage).toContain('record start'); + expect(SCOPE_READ.has('record')).toBe(true); + }); +}); + +describe('encodeFrameDirectory', () => { + test('writes an HTML player when ffmpeg is missing or frames are dummy bytes', () => { + const dir = tmpDir('gstack-enc-'); + fs.writeFileSync(path.join(dir, 'frame_000001.jpg'), Buffer.from('not-a-real-jpeg')); + fs.writeFileSync(path.join(dir, 'frame_000002.jpg'), Buffer.from('also-fake')); + const out = path.join(dir, 'out.mp4'); + const result = encodeFrameDirectory(dir, out, 8); + expect(result.frameCount).toBe(2); + expect(fs.existsSync(result.artifactPath)).toBe(true); + expect(result.kind === 'html' || result.kind === 'mp4' || result.kind === 'webm').toBe(true); + if (result.kind === 'html') { + const html = fs.readFileSync(result.artifactPath, 'utf-8'); + expect(html).toContain('frame_000001.jpg'); + expect(html).toContain('gstack recording'); + } + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('accepts PNG frames from the iOS poller', () => { + const dir = tmpDir('gstack-enc-png-'); + fs.writeFileSync(path.join(dir, 'frame_000001.png'), Buffer.from('png-bytes')); + const result = encodeFrameDirectory(dir, path.join(dir, 'out.mp4'), 4); + expect(result.frameCount).toBe(1); + expect(fs.existsSync(result.artifactPath)).toBe(true); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('throws when the frame directory is empty', () => { + const dir = tmpDir('gstack-enc-empty-'); + expect(() => encodeFrameDirectory(dir, path.join(dir, 'out.mp4'), 8)).toThrow(/No recording frames/); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('ffmpegAvailable is a boolean probe', () => { + expect(typeof ffmpegAvailable()).toBe('boolean'); + }); +}); + +describe('$B record integration', () => { + let testServer: ReturnType; + let bm: BrowserManager; + let baseUrl: string; + const shutdown = async () => {}; + + beforeAll(async () => { + testServer = startTestServer(0); + baseUrl = testServer.url; + bm = new BrowserManager(); + await bm.launch(); + }); + + afterAll(async () => { + try { testServer.server.stop(); } catch {} + try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {} + }); + + afterEach(async () => { + try { + if (recordingStatus().active) { + await handleMetaCommand('record', ['stop'], bm, shutdown); + } + } catch { + // ignore — reset below + } + __resetRecordingForTests(); + }); + + test('start without a page fails closed', async () => { + const fresh = new BrowserManager(); + await fresh.launch(); + try { + await fresh.closeAllPages(); + await expect(handleMetaCommand('record', ['start'], fresh, shutdown)) + .rejects.toThrow(/No active page/); + } finally { + try { await Promise.race([fresh.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {} + } + }); + + test('start then stop writes a RECORDING artifact', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + const dir = tmpDir('gstack-rec-'); + const out = path.join(dir, 'walk.mp4'); + const started = await handleMetaCommand('record', ['start', out, '--fps', '4'], bm, shutdown); + expect(started).toContain('Recording started'); + expect(recordingStatus().active).toBe(true); + + await handleWriteCommand('reload', [], bm); + await Bun.sleep(400); + + const stopped = await handleMetaCommand('record', ['stop'], bm, shutdown); + expect(stopped).toMatch(/^RECORDING: /m); + expect(recordingStatus().active).toBe(false); + const artifact = stopped.split('\n')[0].replace(/^RECORDING:\s*/, ''); + expect(fs.existsSync(artifact)).toBe(true); + expect(fs.statSync(artifact).size).toBeGreaterThan(20); + fs.rmSync(dir, { recursive: true, force: true }); + const framesDir = recordingStatus().framesDir; + if (framesDir && fs.existsSync(framesDir)) fs.rmSync(framesDir, { recursive: true, force: true }); + }); + + test('stop without start throws', async () => { + await expect(handleMetaCommand('record', ['stop'], bm, shutdown)) + .rejects.toThrow(/Not recording/); + }); + + test('unknown start flag throws', async () => { + await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm); + await expect(handleMetaCommand('record', ['start', '--bogus'], bm, shutdown)) + .rejects.toThrow(/Unknown record start flag/); + }); + + test('status is JSON', async () => { + const raw = await handleMetaCommand('record', ['status'], bm, shutdown); + const parsed = JSON.parse(raw); + expect(parsed.active).toBe(false); + }); +}); diff --git a/browse/test/token-registry.test.ts b/browse/test/token-registry.test.ts index 0435aa3358..21240b4b96 100644 --- a/browse/test/token-registry.test.ts +++ b/browse/test/token-registry.test.ts @@ -190,6 +190,8 @@ describe('token-registry', () => { expect(checkScope(info, 'snapshot')).toBe(true); expect(checkScope(info, 'text')).toBe(true); expect(checkScope(info, 'html')).toBe(true); + expect(checkScope(info, 'screenshot')).toBe(true); + expect(checkScope(info, 'record')).toBe(true); }); it('denies write commands with read-only scope', () => { diff --git a/browse/test/tunnel-gate-unit.test.ts b/browse/test/tunnel-gate-unit.test.ts index 6fcdd9e518..dce8bccaf3 100644 --- a/browse/test/tunnel-gate-unit.test.ts +++ b/browse/test/tunnel-gate-unit.test.ts @@ -43,8 +43,9 @@ describe('canDispatchOverTunnel — daemon-config + bootstrap commands rejected' 'launch', 'launch-browser', 'connect', 'disconnect', 'restart', 'stop', 'tunnel-start', 'tunnel-stop', 'token-mint', 'token-revoke', 'cookie-picker', 'cookie-import', - 'inspector-pick', 'extension-inspect', - 'invalid-command-xyz', 'totally-made-up', + 'inspector-pick', 'extension-inspect', + 'record', + 'invalid-command-xyz', 'totally-made-up', ]; for (const cmd of blocked) { test(`rejects '${cmd}'`, () => { diff --git a/demos/booked/index.html b/demos/booked/index.html new file mode 100644 index 0000000000..34ecfab5ea --- /dev/null +++ b/demos/booked/index.html @@ -0,0 +1,438 @@ + + + + + + Booked — flights, without the stress + + + + + + + +
+
+ + Booked mark + Booked + +
+ + Card on file · Visa ••4242 +
+
+ +

Book a flight the way you’d request a car. One destination. A few quiet options. No countdown clocks.

+

Where to?

+ +
+
+
+ Picking up from + San Francisco · SFO +
+ +
+ + + +
+ + +
+

+ Schedules are published 2026 airline times (FlightMapper / airline FIDS). + One-way fares are recent public quotes (Google Flights, Priceline, Trip.com, Southwest) — not a live GDS ticket. + Payment is simulated. Booked never collects a card number and does not issue a real reservation. +

+
+ + + + + + diff --git a/demos/booked/mark.png b/demos/booked/mark.png new file mode 100644 index 0000000000..e5d3680e34 Binary files /dev/null and b/demos/booked/mark.png differ diff --git a/demos/booked/record-mac.sh b/demos/booked/record-mac.sh new file mode 100755 index 0000000000..0c1de11a98 --- /dev/null +++ b/demos/booked/record-mac.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Run Booked on THIS Mac, walk a booking in a visible browser, record it, open it. +# Usage: ./record-mac.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +PORT="${PORT:-8765}" +URL="http://127.0.0.1:${PORT}/" +STAMP="$(date +%Y%m%d-%H%M%S)" +OUT="${ROOT}/../../.gstack/qa-reports/recordings/booked-mac-${STAMP}.mp4" +mkdir -p "$(dirname "$OUT")" +OUT="$(cd "$(dirname "$OUT")" && pwd)/$(basename "$OUT")" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "This script is for your local Mac. This machine is $(uname -s)." + echo "Open a local Cursor agent on the Mac, or run this file there." + exit 2 +fi + +find_browse() { + if [[ -n "${B:-}" && -x "$B" ]]; then + echo "$B" + return + fi + if [[ -x "${GSTACK_BIN:-}/browse" ]]; then + echo "${GSTACK_BIN}/browse" + return + fi + if [[ -x "${GSTACK_HOME:-$HOME/.gstack}/bin/browse" ]]; then + echo "${GSTACK_HOME:-$HOME/.gstack}/bin/browse" + return + fi + local repo + repo="$(cd "$ROOT/../.." && pwd)" + if [[ -f "$repo/browse/src/cli.ts" ]] && command -v bun >/dev/null 2>&1; then + echo "bun $repo/browse/src/cli.ts" + return + fi + return 1 +} + +if ! BROWSE="$(find_browse)"; then + echo "No gstack browse binary on this Mac." + echo "Serving Booked and opening it in your default browser (no \$B record)." + python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$ROOT" & + SERVER_PID=$! + trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT + sleep 0.4 + open "$URL" + echo "OPENED $URL" + echo "Install gstack browse, then re-run for a headed recording that opens when done." + wait "$SERVER_PID" + exit 0 +fi + +echo "BROWSE=$BROWSE" +python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$ROOT" & +SERVER_PID=$! +trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT +sleep 0.4 +curl -fsS -o /dev/null "$URL" + +# shellcheck disable=SC2086 +run() { $BROWSE --headed "$@"; } + +run disconnect >/dev/null 2>&1 || true +run goto "$URL" +run viewport 1440x900 +echo "RECORDING_PATH=$OUT" +run record start "$OUT" --fps 10 --quality 85 +sleep 1 +run click '[data-testid="dest-JFK"]' +sleep 1 +run click '[data-testid="date-1"]' +sleep 1 +run click '[data-testid="flight-DL405"]' +sleep 1 +run click '[data-testid="request"]' +sleep 1 +run click '[data-testid="confirm"]' +sleep 2 +echo "=== stop ===" +run record stop --open +echo "Booked is still at $URL until you Ctrl-C." +wait "$SERVER_PID" diff --git a/docs/gstack-2/ARCHITECTURE.md b/docs/gstack-2/ARCHITECTURE.md index e7a1ab80db..62e1dc7ab2 100644 --- a/docs/gstack-2/ARCHITECTURE.md +++ b/docs/gstack-2/ARCHITECTURE.md @@ -82,7 +82,7 @@ migration map. | Skill | Public top-level modes and preserved refinements | |---|---| | `/plan` | exactly **Discovery, Product, Engineering, DX, Specification, Full chain**, refined to office-hours, CEO, engineering, DX, spec, or autoplan judgment | -| `/qa` | **Report** or **Fix**, refined by web, physical-iOS, or DX surface | +| `/qa` | **Report** or **Fix**, refined by web, physical-iOS, DX, or a requested screen-recording walkthrough | | `/debug` | **Diagnose-only** or **Fix**, refined to general investigation or the physical-iOS fix loop | | `/review` | **Normal, Security, Performance, Deep**, with health and a genuinely independent outside voice selected only when applicable | | `/ship` | **Prepare, Land, Deploy, Monitor, Resume**, refined to PR, queue, docs, deploy setup, land/deploy, or canary modules | diff --git a/docs/gstack-2/SKILL-MIGRATION.md b/docs/gstack-2/SKILL-MIGRATION.md index 163f16e885..4a22f22dd2 100644 --- a/docs/gstack-2/SKILL-MIGRATION.md +++ b/docs/gstack-2/SKILL-MIGRATION.md @@ -2,7 +2,7 @@ Pinned baseline: `bb57306d98c97011b0919c6132705a15b1579781`. -GStack 2 exposes exactly five judgment dispatchers: `plan`, `qa`, `debug`, `review`, and `ship`, plus the `make-pdf` tool skill that installs with the same canonical tree (six discoverable skills total). The specialist bodies from 39 legacy templates remain provenance-pinned internal reference modules (42 at the 2026-08-04 unfreeze; the 2026-08-09 evidence-backed cut wave removed `plan/gstack.md`, `debug/guard.md`, and `debug/unfreeze.md` — routing, composition, and clear-the-boundary judgment now live in the dispatcher tables, the careful+freeze pair, and investigate.md's Scope Lock respectively, each with a named loss-check). The retired 1.x shared onboarding wrapper is excluded from canonical execution, and every carved specialist section is a package-local lazy reference loaded only at its original workflow point. Twenty-three primary modules are mandatory specialist inputs, and 19 supporting modules remain reachable through compatibility routing. +GStack 2 exposes exactly five judgment dispatchers: `plan`, `qa`, `debug`, `review`, and `ship`, plus the `make-pdf` tool skill that installs with the same canonical tree (six discoverable skills total). The specialist bodies from 39 legacy templates remain provenance-pinned internal reference modules (42 at the 2026-08-04 unfreeze; the 2026-08-09 evidence-backed cut wave removed `plan/gstack.md`, `debug/guard.md`, and `debug/unfreeze.md` — routing, composition, and clear-the-boundary judgment now live in the dispatcher tables, the careful+freeze pair, and investigate.md's Scope Lock respectively, each with a named loss-check). The retired 1.x shared onboarding wrapper is excluded from canonical execution, and every carved specialist section is a package-local lazy reference loaded only at its original workflow point. Twenty-three primary modules are mandatory specialist inputs, and 20 supporting modules remain reachable through compatibility routing. The fixed public modes are: QA = `Report | Fix`; Debug = `Diagnose-only | Fix`; Review = `Normal | Security | Performance | Deep`; Ship = `Prepare | Land | Deploy | Monitor | Resume`. Richer legacy modes are internal aliases only. @@ -34,6 +34,7 @@ The fixed public modes are: QA = `Report | Fix`; Debug = `Diagnose-only | Fix`; | `/setup-browser-cookies` | `$qa --mode Report --module setup-browser-cookies` | internal (internal) | no | #679, #879 | | `/pair-agent` | `$qa --mode Report --module pair-agent` | internal (internal) | no | #679, #879 | | `/scrape` | `$qa --mode Report --module scrape` | internal (internal) | no | #679, #2030, #879 | +| `/recording` | `$qa --mode Report --module recording` | internal (internal) | no | — | | `/investigate` | `$debug --mode Diagnose-only --module investigate` | internal (primary) | yes | #679, #2030, #2186, #879 | | `/ios-fix` | `$debug --mode Fix --module ios-fix` | internal (primary) | yes | #679, #879 | | `/careful` | `$debug --mode Diagnose-only --module careful` | internal (internal) | no | #679, #879 | diff --git a/ios-qa/daemon/test/record-session.test.ts b/ios-qa/daemon/test/record-session.test.ts new file mode 100644 index 0000000000..7ea1b0f1fa --- /dev/null +++ b/ios-qa/daemon/test/record-session.test.ts @@ -0,0 +1,86 @@ +/** + * iOS record-session poller: GET /screenshot → numbered PNG frames. + * Does not require a phone. The encode path is covered in browse/test/screencast.test.ts. + */ + +import { describe, test, expect, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { pollLoop, parseNamed, writeState, clearState } from '../../scripts/record-session'; + +const PNG = Buffer.from( + '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a4944415478da63000000020001e221bc330000000049454e44ae426082', + 'hex', +); + +describe('record-session parseNamed', () => { + test('parses daemon, fps, and boolean open', () => { + expect(parseNamed(['--daemon', 'http://127.0.0.1:9', '--fps', '4', '--open'])).toEqual({ + daemon: 'http://127.0.0.1:9', + fps: '4', + open: true, + }); + }); +}); + +describe('record-session pollLoop', () => { + const dirs: string[] = []; + const previousHome = process.env.GSTACK_HOME; + + afterEach(() => { + if (previousHome === undefined) delete process.env.GSTACK_HOME; + else process.env.GSTACK_HOME = previousHome; + for (const d of dirs) { + try { fs.rmSync(d, { recursive: true, force: true }); } catch {} + } + dirs.length = 0; + try { clearState(); } catch {} + }); + + test('writes numbered PNG frames from the daemon screenshot endpoint', async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ios-rec-home-')); + const frames = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ios-rec-frames-')); + dirs.push(home, frames); + process.env.GSTACK_HOME = home; + + let hits = 0; + const server = Bun.serve({ + port: 0, + fetch() { + hits += 1; + return new Response(PNG, { headers: { 'content-type': 'image/png' } }); + }, + }); + + writeState({ + pid: process.pid, + daemonUrl: `http://127.0.0.1:${server.port}`, + token: null, + framesDir: frames, + outputPath: path.join(frames, 'out.mp4'), + fps: 10, + startedAt: Date.now(), + }); + + const poller = pollLoop({ + pid: process.pid, + daemonUrl: `http://127.0.0.1:${server.port}`, + token: null, + framesDir: frames, + outputPath: path.join(frames, 'out.mp4'), + fps: 10, + startedAt: Date.now(), + }); + + await Bun.sleep(250); + clearState(); + await poller; + server.stop(true); + + const pngs = fs.readdirSync(frames).filter((f) => f.endsWith('.png')).sort(); + expect(pngs.length).toBeGreaterThan(0); + expect(pngs[0]).toBe('frame_000001.png'); + expect(hits).toBeGreaterThan(0); + }); +}); diff --git a/ios-qa/scripts/record-session.ts b/ios-qa/scripts/record-session.ts new file mode 100644 index 0000000000..562b35d937 --- /dev/null +++ b/ios-qa/scripts/record-session.ts @@ -0,0 +1,262 @@ +#!/usr/bin/env bun +/** + * Record a physical iPhone session by polling the ios-qa daemon's + * GET /screenshot and encoding the frames (ffmpeg if present, HTML player + * otherwise). Self-contained: the managed ios runtime does not ship browse/src. + * + * bun ios-qa/scripts/record-session.ts start --daemon http://127.0.0.1:PORT [--token T] [--out PATH] [--fps 4] + * bun ios-qa/scripts/record-session.ts stop [--open] + * bun ios-qa/scripts/record-session.ts status + * + * `start` detaches a poller so the skill's next bash block can drive the + * device. State lives at `$GSTACK_HOME/ios-qa-recording.json`. + */ + +import { spawn, spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +interface RecordingState { + pid: number; + daemonUrl: string; + token: string | null; + framesDir: string; + outputPath: string; + fps: number; + startedAt: number; +} + +const TEMP_DIR = process.platform === 'win32' ? os.tmpdir() : '/tmp'; + +function homeDir(): string { + return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); +} + +function statePath(): string { + return path.join(homeDir(), 'ios-qa-recording.json'); +} + +function readState(): RecordingState | null { + const p = statePath(); + if (!fs.existsSync(p)) return null; + try { + return JSON.parse(fs.readFileSync(p, 'utf-8')) as RecordingState; + } catch { + return null; + } +} + +function writeState(state: RecordingState): void { + fs.mkdirSync(homeDir(), { recursive: true }); + fs.writeFileSync(statePath(), JSON.stringify(state, null, 2)); +} + +function clearState(): void { + try { fs.unlinkSync(statePath()); } catch (err: any) { + if (err?.code !== 'ENOENT') throw err; + } +} + +function parseNamed(args: string[]): Record { + const out: Record = {}; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (!a.startsWith('--')) continue; + const key = a.slice(2); + const next = args[i + 1]; + if (!next || next.startsWith('--')) out[key] = true; + else { + out[key] = next; + i++; + } + } + return out; +} + +function ffmpegAvailable(): boolean { + try { + return spawnSync('ffmpeg', ['-version'], { stdio: 'ignore', timeout: 4000, windowsHide: true }).status === 0; + } catch { + return false; + } +} + +function listFrames(framesDir: string): string[] { + if (!fs.existsSync(framesDir)) return []; + return fs.readdirSync(framesDir).filter((f) => /^frame_\d{6}\.(png|jpe?g)$/i.test(f)).sort(); +} + +function encodeFrameDirectory(framesDir: string, outputPath: string, fps: number): { + artifactPath: string; kind: 'mp4' | 'webm' | 'html'; frameCount: number; ffmpeg: boolean; +} { + const frames = listFrames(framesDir); + if (frames.length === 0) throw new Error(`No recording frames in ${framesDir}`); + const ext = path.extname(outputPath).toLowerCase(); + const videoPath = ext === '.webm' || ext === '.mp4' ? outputPath : outputPath.replace(/\.[^.]+$/, '') + '.mp4'; + const pattern = frames[0].endsWith('.png') ? 'frame_%06d.png' : 'frame_%06d.jpg'; + if (ffmpegAvailable()) { + const input = path.join(framesDir, pattern); + const r = spawnSync('ffmpeg', [ + '-y', '-hide_banner', '-loglevel', 'error', '-framerate', String(fps), '-i', input, + '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', videoPath, + ], { stdio: 'ignore', timeout: 120_000, windowsHide: true }); + if (r.status === 0 && fs.existsSync(videoPath) && fs.statSync(videoPath).size > 0) { + return { artifactPath: videoPath, kind: 'mp4', frameCount: frames.length, ffmpeg: true }; + } + } + const htmlPath = path.join(framesDir, 'player.html'); + const rel = frames.map((f) => path.basename(f)); + fs.writeFileSync(htmlPath, `gstack recording`); + return { artifactPath: htmlPath, kind: 'html', frameCount: frames.length, ffmpeg: false }; +} + +function openArtifact(filePath: string): boolean { + const resolved = path.resolve(filePath); + if (!fs.existsSync(resolved)) return false; + const cmd = process.platform === 'darwin' ? ['open', resolved] + : process.platform === 'win32' ? ['cmd', '/c', 'start', '', resolved] + : ['xdg-open', resolved]; + try { + return spawnSync(cmd[0], cmd.slice(1), { stdio: 'ignore', timeout: 8000, windowsHide: true }).status === 0; + } catch { + return false; + } +} + +async function fetchScreenshot(daemonUrl: string, token: string | null): Promise { + const headers: Record = {}; + if (token) headers.Authorization = `Bearer ${token}`; + const res = await fetch(`${daemonUrl.replace(/\/$/, '')}/screenshot`, { headers }); + if (!res.ok) throw new Error(`GET /screenshot ${res.status}`); + return Buffer.from(await res.arrayBuffer()); +} + +async function pollLoop(state: RecordingState): Promise { + fs.mkdirSync(state.framesDir, { recursive: true }); + const interval = Math.max(50, Math.round(1000 / state.fps)); + let n = 0; + const maxFrames = 3600; + const deadline = state.startedAt + 15 * 60 * 1000; + while (Date.now() < deadline && n < maxFrames) { + if (!fs.existsSync(statePath())) break; + try { + const buf = await fetchScreenshot(state.daemonUrl, state.token); + n += 1; + fs.writeFileSync(path.join(state.framesDir, `frame_${String(n).padStart(6, '0')}.png`), buf); + } catch { + // Device may be mid-action; skip this tick. + } + await Bun.sleep(interval); + } +} + +function start(args: string[]): string { + if (readState()) throw new Error('Already recording. Run: record-session.ts stop'); + const flags = parseNamed(args); + const daemon = flags.daemon; + if (typeof daemon !== 'string' || !daemon) { + throw new Error('Usage: record-session.ts start --daemon http://127.0.0.1:PORT [--token T] [--out PATH] [--fps 4]'); + } + const fps = flags.fps ? Number(flags.fps) : 4; + if (!Number.isFinite(fps) || fps < 1 || fps > 15) throw new Error('--fps must be 1-15'); + const outputPath = typeof flags.out === 'string' ? flags.out : path.join(TEMP_DIR, `gstack-recording-${Date.now()}.mp4`); + const token = typeof flags.token === 'string' ? flags.token : null; + const stamp = Date.now(); + const framesDir = path.join(TEMP_DIR, `gstack-ios-recording-frames-${stamp}`); + fs.mkdirSync(framesDir, { recursive: true }); + const outDir = path.dirname(path.resolve(outputPath)); + if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + + const child = spawn(process.execPath, [import.meta.path, 'poll'], { + detached: true, + stdio: 'ignore', + env: process.env, + }); + child.unref(); + if (!child.pid) throw new Error('Failed to detach iOS recording poller'); + + writeState({ + pid: child.pid, + daemonUrl: daemon, + token, + framesDir, + outputPath, + fps: Math.round(fps), + startedAt: stamp, + }); + return `iOS recording started (pid ${child.pid}) → ${outputPath}\nStop with: record-session.ts stop [--open]`; +} + +function stop(args: string[]): string { + const state = readState(); + if (!state) throw new Error('Not recording. Start with: record-session.ts start --daemon URL'); + const flags = parseNamed(args); + try { process.kill(state.pid, 'SIGTERM'); } catch (err: any) { + if (err?.code !== 'ESRCH') throw err; + } + clearState(); + + const encoded = encodeFrameDirectory(state.framesDir, state.outputPath, state.fps); + if (encoded.kind !== 'html') { + try { fs.rmSync(state.framesDir, { recursive: true, force: true }); } catch { /* leave frames */ } + } + const opened = flags.open ? openArtifact(encoded.artifactPath) : false; + const lines = [ + `RECORDING: ${encoded.artifactPath}`, + `kind=${encoded.kind} frames=${encoded.frameCount} ffmpeg=${encoded.ffmpeg}`, + ]; + if (flags.open) lines.push(opened ? 'Opened in the local viewer.' : 'Could not open the viewer; open the RECORDING path yourself.'); + return lines.join('\n'); +} + +function status(): string { + const state = readState(); + if (!state) return JSON.stringify({ active: false }, null, 2); + return JSON.stringify({ + active: true, + pid: state.pid, + outputPath: state.outputPath, + framesDir: state.framesDir, + fps: state.fps, + elapsedMs: Date.now() - state.startedAt, + }, null, 2); +} + +async function main(): Promise { + const [action, ...rest] = process.argv.slice(2); + try { + if (action === 'poll') { + const state = readState(); + if (!state) return; + await pollLoop(state); + return; + } + if (action === 'start') { + console.log(start(rest)); + return; + } + if (action === 'stop') { + console.log(stop(rest)); + return; + } + if (action === 'status') { + console.log(status()); + return; + } + throw new Error('Usage: record-session.ts start --daemon URL | stop [--open] | status'); + } catch (err: any) { + console.error(err?.message ?? err); + process.exit(1); + } +} + +export { parseNamed, start, stop, status, readState, writeState, clearState, statePath, pollLoop }; + +if (import.meta.main) { + void main(); +} diff --git a/skills/.compat/recording/SKILL.md b/skills/.compat/recording/SKILL.md new file mode 100644 index 0000000000..3abb85df0a --- /dev/null +++ b/skills/.compat/recording/SKILL.md @@ -0,0 +1,15 @@ +--- +name: recording +description: >- + Compatibility alias for /recording. Routes to $qa --mode Report --module recording without copying specialist judgment. +metadata: + internal: true +--- + +# Compatibility alias: /recording + +Print this replacement invocation, then dispatch to it exactly: + +`$qa --mode Report --module recording` + +Do not reproduce or summarize the specialist here. The canonical dispatcher must load its preserved `recording` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill qa`. diff --git a/skills/debug/references/COMPATIBILITY.md b/skills/debug/references/COMPATIBILITY.md index aa23883a18..dd02bd45ba 100644 --- a/skills/debug/references/COMPATIBILITY.md +++ b/skills/debug/references/COMPATIBILITY.md @@ -28,6 +28,7 @@ This package is self-contained. Route every retired invocation to the exact repl | `/setup-browser-cookies` | `$qa --mode Report --module setup-browser-cookies` | install `qa` | | `/pair-agent` | `$qa --mode Report --module pair-agent` | install `qa` | | `/scrape` | `$qa --mode Report --module scrape` | install `qa` | +| `/recording` | `$qa --mode Report --module recording` | install `qa` | | `/investigate` | `$debug --mode Diagnose-only --module investigate` | `legacy/investigate.md` | | `/ios-fix` | `$debug --mode Fix --module ios-fix` | `legacy/ios-fix.md` | | `/careful` | `$debug --mode Diagnose-only --module careful` | `legacy/careful.md` | diff --git a/skills/plan/references/COMPATIBILITY.md b/skills/plan/references/COMPATIBILITY.md index 3c0c011d7b..6b7c07e04f 100644 --- a/skills/plan/references/COMPATIBILITY.md +++ b/skills/plan/references/COMPATIBILITY.md @@ -28,6 +28,7 @@ This package is self-contained. Route every retired invocation to the exact repl | `/setup-browser-cookies` | `$qa --mode Report --module setup-browser-cookies` | install `qa` | | `/pair-agent` | `$qa --mode Report --module pair-agent` | install `qa` | | `/scrape` | `$qa --mode Report --module scrape` | install `qa` | +| `/recording` | `$qa --mode Report --module recording` | install `qa` | | `/investigate` | `$debug --mode Diagnose-only --module investigate` | install `debug` | | `/ios-fix` | `$debug --mode Fix --module ios-fix` | install `debug` | | `/careful` | `$debug --mode Diagnose-only --module careful` | install `debug` | diff --git a/skills/qa/SKILL.md b/skills/qa/SKILL.md index 36ce41ab1d..df645229fa 100644 --- a/skills/qa/SKILL.md +++ b/skills/qa/SKILL.md @@ -1,7 +1,7 @@ --- name: qa description: >- - Report on or fix validated product defects. Use for web/browser QA, real-device iOS, developer journeys, or accessibility. + Report on or fix validated product defects. Use for web/browser QA, real-device iOS, developer journeys, accessibility, or recorded walkthroughs. --- # GStack QA @@ -48,7 +48,7 @@ When that trigger fires, read `references/FAST-PATH.md` and follow it. It is bin | Mode | Target | Infer when | Candidate internal specialists | |---|---|---|---| -| `Report` | Any supported test surface | The user asks for evidence or findings without authorizing product-code changes. | `references/legacy/qa-only.md`, `references/legacy/ios-qa.md`, `references/legacy/devex-review.md` | +| `Report` | Any supported test surface | The user asks for evidence or findings without authorizing product-code changes. | `references/legacy/qa-only.md`, `references/legacy/ios-qa.md`, `references/legacy/devex-review.md`, `references/legacy/recording.md` | | `Fix` | Any supported test surface | The user explicitly authorizes validated bug fixes and exact-journey re-verification. | `references/legacy/qa.md` | ## Hard rules @@ -58,6 +58,7 @@ When that trigger fires, read `references/FAST-PATH.md` and follow it. It is bin - Project test, build, and eval commands are project-owned. Before running any, resolve the command from the project's CLAUDE.md (or equivalent project config). If none is declared, ask the user (AskUserQuestion) and persist the answer to the project's CLAUDE.md so it is never asked again. Never probe-run `npm test`, `jest`, `pytest`, `bun test`, or any other framework guess to discover the command. - For APIs, CLIs, backend jobs, workers, and webhooks, activate system-functional with the preserved DX journey and report/fix boundary; run repository-native probes and disclose every untested surface. A non-browser target does not load a browser-centric module. `references/legacy/qa-only.md` and `references/legacy/qa.md` test web applications through a browser; against an API, CLI, job, worker, or webhook they contradict the surface under test, so they go on the Skipped modules line with the reason `non-browser target`, the `browser-*` and `scrape` aliases stay unloaded, and the Evidence, mutation, and exit section of `references/SYSTEM-FUNCTIONAL.md` carries the report-versus-fix boundary in their place. Mandatory means mandatory for its lane, not for every target. - `references/legacy/devex-review.md` audits developer experience, not functional correctness. Read it only when the scope evaluates install, setup, onboarding, upgrade path, or the ergonomics of the API, CLI, or SDK as a product. Depth never triggers it on its own: a Standard or Deep functional pass over an API, CLI, job, worker, or webhook leaves it unloaded, because it measures time to first working call, documentation quality, and ecosystem health, not whether the surface behaves to its contract. When it stays unloaded, name it on the Skipped modules line with the reason `no developer-experience surface`. +- `references/legacy/recording.md` is the walkthrough camera: activate it when the requested artifact is a screen recording, video, or opened recording of an agent using a web or physical iOS app. It starts capture, then loads qa-only or ios-qa (or qa.md when Fix is authorized). It is not a substitute for those specialists. When the artifact is not a recording, name it on the Skipped modules line with the reason `no recording requested`. ## Internal specialist routing aliases @@ -74,6 +75,7 @@ Every specialist below is an internal implementation detail, including mandatory | `/setup-browser-cookies` | `browser-auth` | `Report` | supporting | `references/legacy/setup-browser-cookies.md` | | `/pair-agent` | `browser-pair` | `Report` | supporting | `references/legacy/pair-agent.md` | | `/scrape` | `scrape` | `Report` | supporting | `references/legacy/scrape.md` | +| `/recording` | `recording` | `Report` | supporting | `references/legacy/recording.md` | ## Completeness invariant diff --git a/skills/qa/agents/openai.yaml b/skills/qa/agents/openai.yaml index 14eadaeff4..de977d10b4 100644 --- a/skills/qa/agents/openai.yaml +++ b/skills/qa/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "GStack QA" - short_description: "Test, evidence, fix, and monitor products" + short_description: "Test, evidence, fix, monitor, and record products" default_prompt: "Use $qa to test this product and choose report-only or fix-and-verify behavior." diff --git a/skills/qa/references/COMPATIBILITY.md b/skills/qa/references/COMPATIBILITY.md index 85be9a675a..c29ffb21d3 100644 --- a/skills/qa/references/COMPATIBILITY.md +++ b/skills/qa/references/COMPATIBILITY.md @@ -27,6 +27,7 @@ This package is self-contained. Route every retired invocation to the exact repl | `/setup-browser-cookies` | `$qa --mode Report --module setup-browser-cookies` | `legacy/setup-browser-cookies.md` | | `/pair-agent` | `$qa --mode Report --module pair-agent` | `legacy/pair-agent.md` | | `/scrape` | `$qa --mode Report --module scrape` | `legacy/scrape.md` | +| `/recording` | `$qa --mode Report --module recording` | `legacy/recording.md` | | `/investigate` | `$debug --mode Diagnose-only --module investigate` | install `debug` | | `/ios-fix` | `$debug --mode Fix --module ios-fix` | install `debug` | | `/careful` | `$debug --mode Diagnose-only --module careful` | install `debug` | diff --git a/skills/qa/references/legacy/recording.md b/skills/qa/references/legacy/recording.md new file mode 100644 index 0000000000..16cc705e0b --- /dev/null +++ b/skills/qa/references/legacy/recording.md @@ -0,0 +1,138 @@ +## Host-neutral runtime bindings + +These assignments select stable paths only; they do not install anything or grant consent: + +```bash +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +GSTACK_ROOT="$GSTACK_HOME" +GSTACK_STATE_ROOT="$GSTACK_HOME" +GSTACK_BIN="$GSTACK_HOME/bin" +BUN_CMD="$GSTACK_BIN/bun" +B="$GSTACK_BIN/browse" +P="$GSTACK_BIN/make-pdf" +``` + +# $qa --mode Report --module recording — record an agent using the app, then open it + +The user asked for a **screen recording** of an agent walking a web app or a physical iOS app, plus full QA, and for that recording to **open when it is done**. + +This module owns start/stop/open. It does not replace QA judgment: after recording is rolling, load and execute `references/legacy/qa-only.md` (web) or `references/legacy/ios-qa.md` (device). Report-only unless the user explicitly authorized product-code fixes, in which case load `references/legacy/qa.md` instead of qa-only after the same recording start. + +Do not add a sixth public skill. `/recording` is an opt-in alias for this module. `$B record` is the web affordance; `ios-qa/scripts/record-session.ts` is the device affordance. Pair-agent tunnel tokens cannot invoke `$B record`. + +## Parse the request + +| Signal | Action | +|---|---| +| URL, localhost, "web app", "site", "frontend" | Web surface | +| iPhone, device, "iOS app", UDID, DebugBridge | iOS surface | +| Both, or neither | Prefer web if a local/dev URL is reachable; otherwise iOS if a daemon is up; otherwise ask once | +| "watch", "show me", "demo", "headed" | Visible browser (web) or iOS demo mode (device) in addition to the recording | +| "fix", "patch", "make it work" | Mutation = Fix; load qa.md after recording starts. Default is report-only | +| Output path | Honor it. Default: `.gstack/qa-reports/recordings/qa-.mp4` | + +If no URL is given on a feature branch, use qa-only's diff-aware local-app detection after recording is ready to start — but **start recording before the first interaction**, not after. + +## SETUP (run BEFORE any browse or device command) + +```bash +_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +B="" +[ -n "$_ROOT" ] && [ -x "$GSTACK_BIN/browse" ] && B="$GSTACK_BIN/browse" +[ -z "$B" ] && B="${GSTACK_HOME:-$HOME/.gstack}/bin/browse" +if [ -x "$B" ]; then + echo "READY: $B" +else + echo "NEEDS_SETUP" +fi +``` + +If `NEEDS_SETUP` on a **web** target: tell the user the browser-backed capability is not ready, offer the local setup options (managed Chromium or a detected installed Chromium) with no network access or changes, then STOP. Read `references/RUNTIME.md` and follow its capability bootstrap. Never assume a standard-installed skill directory contains `./setup`. Never download a second Bun. + +If the target is **iOS** and the daemon is not up, follow `references/legacy/ios-qa.md` Phase 0–2 first, then return here to start the recorder before Phase 3. + +```bash +REPORT_DIR=".gstack/qa-reports" +STAMP=$(date +%Y%m%d-%H%M%S) +RECORDING="$REPORT_DIR/recordings/qa-$STAMP.mp4" +mkdir -p "$REPORT_DIR/recordings" "$REPORT_DIR/screenshots" +echo "RECORDING_PATH=$RECORDING" +``` + +Carry `RECORDING_PATH` in prose ("the recording path created in SETUP") — each bash block is a new shell. + +## Web surface + +1. Find the browse binary (SETUP above). Headless is the default. Only if the user asked to **watch live**, read `references/legacy/open-gstack-browser.md` and `$B connect` first — do not offer headed Chromium for ordinary recorded QA. +2. Navigate to the app (`$B goto ` or qa-only's local-port probe). Recording needs an active page; `record start` fails with "No active page" otherwise. +3. Start capturing **before** the QA sweep: + +```bash +$B record start "" --fps 8 +``` + +4. Read `references/legacy/qa-only.md` completely (or `references/legacy/qa.md` if mutation is Fix) and execute it. Every click, fill, and snapshot happens while the screencast is running. Still take per-finding screenshots — the video is the walkthrough, screenshots remain the evidence map. +5. Stop, encode, and open: + +```bash +$B record stop --open +``` + +6. The stop output starts with `RECORDING: `. Print that path in the final reply. If open failed (no viewer, headless CI), say so and leave the path clickable. Do not claim the user has watched the video. + +`$B record status` is the heartbeat if a later step is unsure whether capture is still running. Do not start a second recording; stop the first. + +ffmpeg is optional. When it is missing, stop writes an HTML player next to the JPEG frames and opens that instead. That still counts as opening the recording. + +## iOS surface + +Physical device only. No simulator, no XCTest, no WebDriverAgent, no cloud device farm. + +1. Bootstrap with `references/legacy/ios-qa.md` through daemon-up (Phases 0–2). Turn on **Recording mode** (`--recording`) so DebugOverlay watermarks the screencast "AGENT DEMO". If the user said demo/watch/show me, also apply that module's Demo mode (visible taps only, 4 fps). +2. Resolve the recorder: + +```bash +_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +IOS_RECORD="$($GSTACK_BIN/gstack runtime path ios-qa/scripts/record-session.ts 2>/dev/null || true)" +[ -z "$IOS_RECORD" ] && [ -n "$_ROOT" ] && IOS_RECORD="$_ROOT/ios-qa/scripts/record-session.ts" +if [ -f "$IOS_RECORD" ]; then echo "IOS_RECORD=$IOS_RECORD"; else echo "IOS_RECORD_MISSING"; fi +``` + +If `IOS_RECORD_MISSING`, say the iOS recorder is part of the managed runtime and STOP with the runtime bootstrap offer for capability `ios`. Do not invent a different device backend. + +3. Start the poller against the daemon (the port from ios-qa session cache / Phase 2). Default 4 fps: + +```bash +"$BUN_CMD" "$IOS_RECORD" start --daemon "http://127.0.0.1:" --out "" --fps 4 +``` + +Pass `--token` only when the daemon requires a bearer for `/screenshot`. + +4. Execute ios-qa Phase 3 (vision-driven loop) against the user's test goal. The poller keeps capturing independently of each `/tap`. +5. Stop and open: + +```bash +"$BUN_CMD" "$IOS_RECORD" stop --open +``` + +Same `RECORDING:` line contract as web. + +## Full QA contract + +"Full QA" here means the preserved specialist, not a screenshot slideshow: + +- Web: qa-only's modes (diff-aware / quick / full / regression) and scoring. Prefer **full** unless the user said `--quick`. +- iOS: the closed find→verify loop on the real device, not a single screenshot. +- Do not skip console, network, accessibility, or forms because a camera is running. +- Do not use `POST /state/*` writes to skip UI during a recording the user will watch — that is ios-qa demo-mode's rule, and it applies to this module whenever the artifact is a walkthrough. + +## Exit + +Report, in this order: + +1. **RECORDING:** absolute path, kind (mp4 / webm / html), whether it opened. +2. The QA report path from the specialist you loaded. +3. Findings count by severity. Each finding still has a screenshot. +4. Surfaces you did not test. + +If recording started and QA later fails, still `record stop --open` (or the iOS stop) so the user gets the partial walkthrough. Never leave a screencast running. diff --git a/skills/review/references/COMPATIBILITY.md b/skills/review/references/COMPATIBILITY.md index 885f77d02f..258b778ce7 100644 --- a/skills/review/references/COMPATIBILITY.md +++ b/skills/review/references/COMPATIBILITY.md @@ -28,6 +28,7 @@ This package is self-contained. Route every retired invocation to the exact repl | `/setup-browser-cookies` | `$qa --mode Report --module setup-browser-cookies` | install `qa` | | `/pair-agent` | `$qa --mode Report --module pair-agent` | install `qa` | | `/scrape` | `$qa --mode Report --module scrape` | install `qa` | +| `/recording` | `$qa --mode Report --module recording` | install `qa` | | `/investigate` | `$debug --mode Diagnose-only --module investigate` | install `debug` | | `/ios-fix` | `$debug --mode Fix --module ios-fix` | install `debug` | | `/careful` | `$debug --mode Diagnose-only --module careful` | install `debug` | diff --git a/skills/ship/references/COMPATIBILITY.md b/skills/ship/references/COMPATIBILITY.md index d8792925b2..4992419251 100644 --- a/skills/ship/references/COMPATIBILITY.md +++ b/skills/ship/references/COMPATIBILITY.md @@ -28,6 +28,7 @@ This package is self-contained. Route every retired invocation to the exact repl | `/setup-browser-cookies` | `$qa --mode Report --module setup-browser-cookies` | install `qa` | | `/pair-agent` | `$qa --mode Report --module pair-agent` | install `qa` | | `/scrape` | `$qa --mode Report --module scrape` | install `qa` | +| `/recording` | `$qa --mode Report --module recording` | install `qa` | | `/investigate` | `$debug --mode Diagnose-only --module investigate` | install `debug` | | `/ios-fix` | `$debug --mode Fix --module ios-fix` | install `debug` | | `/careful` | `$debug --mode Diagnose-only --module careful` | install `debug` | diff --git a/test/skill-baseline/scenarios-qa-plan.test.ts b/test/skill-baseline/scenarios-qa-plan.test.ts index 53fa6029cf..0783c4013a 100644 --- a/test/skill-baseline/scenarios-qa-plan.test.ts +++ b/test/skill-baseline/scenarios-qa-plan.test.ts @@ -252,7 +252,7 @@ describe('module coverage', () => { } const uncovered = onDisk.filter((m) => !coverage[m]); expect(uncovered).toEqual([]); - expect(onDisk.length).toBe(22); + expect(onDisk.length).toBe(23); }); test('every module names at least one scenario, and every named scenario exists', () => { diff --git a/test/skill-baseline/scenarios-qa-plus.ts b/test/skill-baseline/scenarios-qa-plus.ts index a6fc51eece..f001bddb8b 100644 --- a/test/skill-baseline/scenarios-qa-plus.ts +++ b/test/skill-baseline/scenarios-qa-plus.ts @@ -1537,6 +1537,121 @@ const iosBuild: Scenario = { }, }; +// --- 13. recording walkthrough (recording.md: start before QA, open after) ---- + +const RECORDING_WEB: Record = { + 'qa/walkthrough.sh': `#!/usr/bin/env bash +set -euo pipefail +# User asked: "record an agent using the app and open the video when you're done." +B="\${GSTACK_BIN:-$HOME/.gstack}/bin/browse" +"$B" goto http://127.0.0.1:3000 +"$B" snapshot -i -a -o .gstack/qa-reports/screenshots/initial.png +"$B" click @e3 +"$B" screenshot .gstack/qa-reports/screenshots/after-click.png +# A zero-byte file is left where the video should be. Nothing ever ran +# \`record start\`, and nothing opens a recording at the end. +: > .gstack/qa-reports/recordings/qa-walkthrough.mp4 +echo "QA complete. Video is at .gstack/qa-reports/recordings/qa-walkthrough.mp4" +`, + 'qa/note.md': `SEEDED FALSE POSITIVE. Per-finding screenshots are still required during a +recorded run. The walkthrough camera does not replace the evidence map, so +flagging \`snapshot -a\` / \`screenshot\` as the defect would punish the arm +that kept them. The defect is that capture never started and the file was +never opened. +`, +}; + +const recordingWeb: Scenario = { + id: 'qa-plus-recording-web', + skill: QA, + source: ['test/skill-baseline/scenarios-qa-plus.ts'], + task: + 'The user asked for a screen recording of an agent using this web app, full QA, and for the recording to open when done. Audit qa/walkthrough.sh. Report every defect with the file and line.' + + AUDIT_TAIL, + materialize: (d) => writeAll(d, RECORDING_WEB), + truth: { + expected: exp( + [ + 'never-started', + 'record start|never (started|ran) (a )?record|no (screencast|recording started)|\\$B record', + 'capture must start before the first interaction', + ], + [ + 'empty-video', + 'zero-byte|: > |empty (mp4|file|video)|not a (real )?recording', + 'the mp4 is a truncated empty file, not an encoded screencast', + ], + [ + 'never-opened', + 'record stop --open|never open|did not open|open (the )?(recording|video|file)', + 'the user asked for the recording to open when QA finished', + ], + ), + traps: traps( + ['screenshots-still-required', 'qa/note\\.md|walkthrough camera', 'screenshots remain the evidence map during a recorded run'], + ), + }, +}; + +const RECORDING_IOS: Record = { + 'Scripts/demo-record.sh': `#!/usr/bin/env bash +set -euo pipefail +# User asked to watch an agent demo on the phone and get the recording opened. +DAEMON=http://127.0.0.1:8411 +# Skip DebugOverlay --recording (no AGENT DEMO watermark). +# Skip record-session.ts entirely. +curl -sf -X POST "$DAEMON/session/acquire" +# Skip the visible UI and write state so the demo "looks instant" on camera. +curl -sf -X POST "$DAEMON/state/onboardingComplete" -d '{"value":true}' +curl -sf "$DAEMON/screenshot" -o /tmp/final.png +curl -sf -X POST "$DAEMON/session/release" +echo "Demo finished" +`, + 'Scripts/harness-note.md': `SEEDED FALSE POSITIVE. GET /screenshot is the observe-tier read the daemon +is supposed to expose. Flagging the screenshot fetch as a second backend +would punish a correct observe call. The defects are: no recorder process, +no AGENT DEMO watermark, and POST /state writes that skip the UI the +viewer was promised. +`, +}; + +const recordingIos: Scenario = { + id: 'qa-plus-recording-ios', + skill: QA, + source: ['test/skill-baseline/scenarios-qa-plus.ts'], + task: + 'The user asked for a screen recording of an agent demoing this iPhone app, and for that recording to open when done. Audit Scripts/demo-record.sh. Report every defect with the file and line.' + + AUDIT_TAIL, + materialize: (d) => writeAll(d, RECORDING_IOS), + truth: { + expected: exp( + [ + 'no-recorder', + 'record-session|never (started|ran) (the )?record|no (poller|recorder)|ios-qa/scripts/record-session', + 'the iOS poller never started', + ], + [ + 'no-watermark', + '--recording|AGENT DEMO|watermark|DebugOverlay', + 'recording mode watermarks the screencast so it is unambiguously agent-driven', + ], + [ + 'state-write-skips-ui', + 'POST /state|state/onboardingComplete|skip(s|ped)? (the )?UI|visible (tap|UI)|demo mode', + 'a walkthrough the user will watch must drive visible taps, not state writes', + ], + [ + 'never-opened-ios', + 'stop --open|never open|did not open|open (the )?(recording|video)', + 'the recording was never opened for the user', + ], + ), + traps: traps( + ['observe-screenshot', 'harness-note\\.md|observe-tier', 'observe-tier screenshot is the correct daemon read'], + ), + }, +}; + // --- the set ----------------------------------------------------------------- export const qaPlusScenarios: Scenario[] = [ @@ -1552,6 +1667,8 @@ export const qaPlusScenarios: Scenario[] = [ runtime, iosHarness, iosBuild, + recordingWeb, + recordingIos, ]; /** @@ -1574,4 +1691,5 @@ export const QA_PLUS_MODULE_COVERAGE: Record = { 'qa/setup-browser-cookies.md': ['qa-plus-secret-hygiene', 'qa-plus-cookie-scope-consent'], 'qa/open-gstack-browser.md': ['qa-plus-runtime-escalation', 'qa-plus-cookie-scope-consent'], 'qa/ios-qa.md': ['qa-plus-ios-harness-contract', 'qa-plus-ios-build-failure'], + 'qa/recording.md': ['qa-plus-recording-web', 'qa-plus-recording-ios'], };