From 5a9dd381f39dbd17e20157eea805ab3008fed0cb Mon Sep 17 00:00:00 2001 From: Lior Date: Tue, 26 May 2026 02:20:59 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(B-0421):=20tools/peer-call/grok-build.?= =?UTF-8?q?ts=20=E2=80=94=20wraps=20native=20Grok-Build=20CLI;=20closes=20?= =?UTF-8?q?broken=20cursor-agent=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aaron 2026-05-26 installed the native Grok-Build CLI (`grok`) during the iter-5 session. The CLI is explicitly Claude-Code- compatible: --allow / --deny rules (Claude Code: --allowedTools) --permission-mode default|acceptEdits|auto|dontAsk|bypassPermissions|plan --system-prompt-override (Claude Code: --system-prompt) -p / --single for headless single-turn --output-format plain|json|streaming-json --reasoning-effort --best-of-n parallel execution -r / --resume session continuity agent subcommand for headless mode MCP servers, plugin/marketplace, cross-session memory Supersedes tools/peer-call/grok.ts (cursor-agent wrapper; broken since 2026-05-11 per B-0421 — cursor-agent exit 1 / empty output). Old grok.ts retained for back-compat / reference. Wrapper conventions mirror claude.ts + grok.ts + codex.ts: - Input firewall via _firewall.peerFirewallCheck + GROK_SUBSTANTIVE_TRIGGERS (rejects rote heartbeats; bypass via --allow-empty) - --file includes file head as context - --context-cmd includes allow-listed git/gh/rg output as context - --output-file + auto-generated /tmp/peer-call-output/-grok-build.md - OUTPUT-FILE: marker on stdout for shell callers to recover full response via tail -1 - Exit codes: 0 success / 1 invocation error / 2 grok non-zero / 3 firewall reject Routing: `grok -p "$PROMPT" --allow Read,Glob,Grep --permission-mode auto --output-format plain` (read-only blast radius matching claude.ts; --reasoning-effort high added with --thinking flag). Empirical validation 2026-05-26: - Firewall rejects heartbeat ("hi") with substantive-trigger failure (exit 3) - Firewall bypass via --allow-empty + live grok call: prompted "Say only the literal word PONG and nothing else." → response "PONG\n" → OUTPUT-FILE marker emitted correctly - TS strict compile clean Composes with .claude/rules/peer-call-infrastructure.md (canonical peer-call wrapper inventory; this is the 9th wrapper / 8th substantive peer surface). Closes B-0421. Co-Authored-By: Claude Opus 4.7 --- tools/peer-call/grok-build.ts | 349 ++++++++++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 tools/peer-call/grok-build.ts diff --git a/tools/peer-call/grok-build.ts b/tools/peer-call/grok-build.ts new file mode 100644 index 0000000000..d4909c3200 --- /dev/null +++ b/tools/peer-call/grok-build.ts @@ -0,0 +1,349 @@ +#!/usr/bin/env bun +// grok-build.ts — Claude-Code-side caller for invoking Grok as a peer +// reviewer via the native Grok-Build CLI (xAI's Claude-Code-compatible +// agent harness, installed as `grok`). +// +// Supersedes `tools/peer-call/grok.ts` (which wraps cursor-agent and +// has been broken since 2026-05-11 per B-0421 — cursor-agent exit 1 / +// empty output). The old `grok.ts` is retained for back-compat reference +// but new peer-calls should target this wrapper. +// +// The native Grok-Build CLI is explicitly Claude-Code-compatible: +// - `--allow` / `--deny` rules (Claude Code: --allowedTools) +// - `--permission-mode default|acceptEdits|auto|dontAsk|bypassPermissions|plan` +// - `--system-prompt-override` (Claude Code: --system-prompt) +// - `-p, --single ` for headless single-turn (analog of `claude -p`) +// - `--output-format plain|json|streaming-json` for structured output +// - `--reasoning-effort ` for reasoning-model effort +// - `--best-of-n ` for parallel best-of execution +// - `-r, --resume []` for session continuity +// - `agent` subcommand for headless mode +// - MCP server configs, plugin/marketplace, cross-session memory +// +// Empirical anchor 2026-05-26: Aaron installed `grok` CLI during the +// iter-5 substrate session; the `--help` output confirmed full +// Claude-Code parity. This wrapper closes B-0421 by replacing the +// cursor-agent dependency with the native `grok -p` flow. +// +// Usage: +// bun tools/peer-call/grok-build.ts "prompt text" +// bun tools/peer-call/grok-build.ts --thinking "prompt text" +// bun tools/peer-call/grok-build.ts --file path/to/file.md "prompt text" +// bun tools/peer-call/grok-build.ts --context-cmd "git diff HEAD~3..HEAD" "prompt" +// bun tools/peer-call/grok-build.ts --output-file PATH "prompt text" +// bun tools/peer-call/grok-build.ts --json "prompt text" +// bun tools/peer-call/grok-build.ts --allow-empty "prompt" # bypass firewall +// +// Routing: wraps `grok -p "PROMPT" --allow Read,Glob,Grep +// --permission-mode auto --output-format plain` (read-only blast radius +// matching claude.ts; auto-permission-mode for autonomous-loop friendly +// invocation; --reasoning-effort high added with --thinking). +// +// Output capture (Class B fix for vera-output-capture-pagination): +// stdout is teed to a file under /tmp/peer-call-output/-grok-build.md +// (auto-generated path) or to --output-file PATH if specified, with a +// final "OUTPUT-FILE: " marker on stdout so shell callers using +// `tail -1` can recover the path and read the FULL reply without +// truncation. Mirrors codex.ts / riven.ts / grok.ts shape. +// +// Exit codes: +// 0 — Grok-Build responded successfully +// 1 — invocation error (bad arguments, grok CLI missing, etc.) +// 2 — Grok-Build returned a non-zero exit (response captured to stderr) +// 3 — input-firewall rejected the prompt as not work-extractable + +import { closeSync, mkdirSync, openSync, readSync, statSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { dirname } from "node:path"; +import { + formatBypassMessage, + formatRejectionMessage, + GROK_SUBSTANTIVE_TRIGGERS, + peerFirewallCheck, +} from "./_firewall"; + +const SPAWN_MAX_BUFFER = 64 * 1024 * 1024; +const FILE_HEAD_BYTES = 20000; +const CTX_HEAD_BYTES = 20000; +const GROK_CLI = "grok"; + +// Read-only tools: enables cold-boot verification + substrate review +// while limiting blast radius (no writes, no bash execution). Matches +// claude.ts CLAUDE_TOOLS pattern. +const GROK_ALLOW_RULES = "Read,Glob,Grep"; + +type OutputFormat = "plain" | "json" | "streaming-json"; + +interface Args { + readonly thinking: boolean; + readonly outputFormat: OutputFormat; + readonly file: string; + readonly contextCmd: string; + readonly prompt: string; + readonly allowEmpty: boolean; + readonly outputFile: string; +} + +interface ArgError { + readonly error: string; +} + +function parseArgs(argv: readonly string[]): Args | ArgError { + let thinking = false; + let outputFormat: OutputFormat = "plain"; + let file = ""; + let contextCmd = ""; + let allowEmpty = false; + let outputFile = ""; + const positional: string[] = []; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + if (a === "--thinking") { + thinking = true; + continue; + } + if (a === "--json") { + outputFormat = "json"; + continue; + } + if (a === "--file") { + const next = argv[i + 1]; + if (!next || next.startsWith("-")) { + return { error: "--file requires a path argument" }; + } + file = next; + i++; + continue; + } + if (a === "--context-cmd") { + const next = argv[i + 1]; + if (!next || next.startsWith("-")) { + return { error: "--context-cmd requires a command string argument" }; + } + contextCmd = next; + i++; + continue; + } + if (a === "--output-file") { + const next = argv[i + 1]; + if (!next || next.startsWith("-")) { + return { error: "--output-file requires a path argument" }; + } + outputFile = next; + i++; + continue; + } + if (a === "--allow-empty") { + allowEmpty = true; + continue; + } + if (a === "-h" || a === "--help") { + return { + error: + "Usage: bun tools/peer-call/grok-build.ts [flags] \n" + + " --thinking use high reasoning effort\n" + + " --json output format json (default: plain)\n" + + " --file include file content as context\n" + + " --context-cmd include allow-listed git/gh/rg cmd output as context\n" + + " --output-file write full response to path (default: /tmp/peer-call-output/-grok-build.md)\n" + + " --allow-empty bypass input-firewall substantive-trigger check\n", + }; + } + if (a.startsWith("-")) { + return { error: `unknown flag: ${a}` }; + } + positional.push(a); + } + + if (positional.length === 0) { + return { error: "no prompt provided (pass as positional argument or via --file)" }; + } + if (positional.length > 1) { + return { + error: `expected exactly 1 positional prompt argument; got ${positional.length}: ${positional.join(" | ")}`, + }; + } + return { + thinking, + outputFormat, + file, + contextCmd, + prompt: positional[0]!, + allowEmpty, + outputFile, + }; +} + +function readFileHead(path: string, maxBytes: number): string { + try { + const st = statSync(path); + const fd = openSync(path, "r"); + const buf = Buffer.alloc(Math.min(maxBytes, st.size)); + readSync(fd, buf, 0, buf.length, 0); + closeSync(fd); + let text = buf.toString("utf8"); + if (st.size > maxBytes) { + text += `\n\n[... file truncated; ${st.size - maxBytes} bytes elided ...]`; + } + return text; + } catch (e) { + return `[file-read-error: ${path}: ${e instanceof Error ? e.message : String(e)}]`; + } +} + +type AllowedContextExecutable = "git" | "gh" | "rg"; + +function parseContextCmd(cmd: string): { executable: AllowedContextExecutable; args: string[] } | { error: string } { + const trimmed = cmd.trim(); + if (trimmed === "") return { error: "context-cmd is empty" }; + const parts = trimmed.split(/\s+/); + const head = parts[0]; + if (head !== "git" && head !== "gh" && head !== "rg") { + return { error: `context-cmd executable must be one of git, gh, rg; got: ${head}` }; + } + return { executable: head, args: parts.slice(1) }; +} + +function runContextCmd(cmd: string, maxBytes: number): string { + const parsed = parseContextCmd(cmd); + if ("error" in parsed) { + return `[context-cmd-parse-error: ${parsed.error}]`; + } + try { + const r = spawnSync(parsed.executable, parsed.args, { + encoding: "utf8", + maxBuffer: SPAWN_MAX_BUFFER, + }); + if (r.error) { + return `[context-cmd-error: ${parsed.executable}: ${r.error.message}]`; + } + let text = r.stdout || ""; + if (text.length > maxBytes) { + text = text.slice(0, maxBytes) + `\n\n[... context-cmd output truncated; ${text.length - maxBytes} bytes elided ...]`; + } + return text; + } catch (e) { + return `[context-cmd-exception: ${e instanceof Error ? e.message : String(e)}]`; + } +} + +function buildFullPrompt(args: Args): string { + const blocks: string[] = []; + if (args.file) { + blocks.push(`# File context: ${args.file}\n\n${readFileHead(args.file, FILE_HEAD_BYTES)}`); + } + if (args.contextCmd) { + blocks.push(`# Context-cmd: \`${args.contextCmd}\`\n\n\`\`\`\n${runContextCmd(args.contextCmd, CTX_HEAD_BYTES)}\n\`\`\``); + } + blocks.push(`# Prompt\n\n${args.prompt}`); + return blocks.join("\n\n---\n\n"); +} + +function defaultOutputPath(): string { + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + return `/tmp/peer-call-output/${ts}-grok-build.md`; +} + +function main(): number { + const parsed = parseArgs(process.argv.slice(2)); + if ("error" in parsed) { + process.stderr.write(`grok-build.ts: ${parsed.error}\n`); + return 1; + } + + // Input firewall: gate on substantive triggers unless --allow-empty. + // peerFirewallCheck returns `string | null` — string is the rejection + // reason; null means the prompt passed the substantive-trigger gate. + if (!parsed.allowEmpty) { + const rejectionReason = peerFirewallCheck(parsed.prompt, GROK_SUBSTANTIVE_TRIGGERS); + if (rejectionReason !== null) { + process.stderr.write(formatRejectionMessage("grok-build", rejectionReason) + "\n"); + return 3; + } + } else { + process.stderr.write(formatBypassMessage("grok-build") + "\n"); + } + + const fullPrompt = buildFullPrompt(parsed); + + // Build grok CLI args. The native grok CLI is Claude-Code-compatible: + // -p / --single : headless single-turn (analog of `claude -p`) + // --allow : permission allow rule(s) (Claude Code: --allowedTools) + // --permission-mode auto : auto-approve safe ops; matches autonomous-loop intent + // --output-format plain|json : structured output + // --reasoning-effort high : for reasoning models (gated by --thinking flag) + const grokArgs: string[] = [ + "-p", + fullPrompt, + "--allow", + GROK_ALLOW_RULES, + "--permission-mode", + "auto", + "--output-format", + parsed.outputFormat, + ]; + if (parsed.thinking) { + grokArgs.push("--reasoning-effort", "high"); + } + + // Determine output file path + ensure dir exists + const outPath = parsed.outputFile || defaultOutputPath(); + try { + mkdirSync(dirname(outPath), { recursive: true }); + } catch (e) { + process.stderr.write( + `grok-build.ts: failed to create output dir ${dirname(outPath)}: ${e instanceof Error ? e.message : String(e)}\n`, + ); + return 1; + } + + // Spawn grok CLI + const r = spawnSync(GROK_CLI, grokArgs, { + encoding: "utf8", + maxBuffer: SPAWN_MAX_BUFFER, + }); + if (r.error) { + process.stderr.write( + `grok-build.ts: failed to spawn ${GROK_CLI}: ${r.error.message}\n` + + `(is the grok CLI installed + on PATH? install via 'curl ... | sh' from xAI per their docs)\n`, + ); + return 1; + } + if (r.status !== 0) { + process.stderr.write(`grok-build.ts: grok exited ${r.status}\n`); + if (r.stderr) process.stderr.write(r.stderr); + return 2; + } + + const response = r.stdout || ""; + + // Write full response to output file + try { + writeFileSync(outPath, response, "utf8"); + } catch (e) { + process.stderr.write( + `grok-build.ts: failed to write output file ${outPath}: ${e instanceof Error ? e.message : String(e)}\n`, + ); + return 1; + } + + // Tee response to stdout (capped to first ~20k chars for inline display; + // full response always available at outPath via the OUTPUT-FILE marker) + const INLINE_CAP = 20000; + if (response.length > INLINE_CAP) { + process.stdout.write(response.slice(0, INLINE_CAP)); + process.stdout.write( + `\n\n[... response truncated inline; ${response.length - INLINE_CAP} bytes elided ...]\n`, + ); + } else { + process.stdout.write(response); + } + + // Always emit the OUTPUT-FILE marker last so `tail -1` recovers the path + process.stdout.write(`\nOUTPUT-FILE: ${outPath}\n`); + + return 0; +} + +process.exit(main()); From 3fd857cba7bdad210aa9ef27728a1afa35c78033 Mon Sep 17 00:00:00 2001 From: Lior Date: Tue, 26 May 2026 02:24:57 -0400 Subject: [PATCH 2/3] =?UTF-8?q?fix(B-0421):=20mitigate=202=20CodeQL=20find?= =?UTF-8?q?ings=20on=20#5110=20=E2=80=94=20TOCTOU=20on=20stat+open=20+=20i?= =?UTF-8?q?nsecure=20temp=20file=20(mirror=20claude.ts=20patterns)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TOCTOU (readFileHead): no longer uses statSync.size to size buffer; allocates fixed maxBytes-sized buffer + reads what fits. Pre-check via isRegularFile is best-effort but the alloc-size no longer depends on stat result. - Insecure temp file (defaultOutputPath): add random 6-char base36 suffix; add PEER_CALL_OUTPUT_DIR env override; fall back to os.tmpdir if /tmp/peer-call- output not writable. - New writeOutputExclusive: opens with 'wx' (exclusive create — fails if path exists, preventing symlink-overwrite) + mode 0o600. - Explicit operator paths use plain 'w' write (operator chose path; respect intent) but still mode 0o600. Live smoke retest: PONG round-trip works; new filename pattern includes random suffix. TS strict compile clean. Co-Authored-By: Claude Opus 4.7 --- tools/peer-call/grok-build.ts | 124 +++++++++++++++++++++++++++------- 1 file changed, 99 insertions(+), 25 deletions(-) diff --git a/tools/peer-call/grok-build.ts b/tools/peer-call/grok-build.ts index d4909c3200..644a9aff2b 100644 --- a/tools/peer-call/grok-build.ts +++ b/tools/peer-call/grok-build.ts @@ -52,9 +52,10 @@ // 2 — Grok-Build returned a non-zero exit (response captured to stderr) // 3 — input-firewall rejected the prompt as not work-extractable -import { closeSync, mkdirSync, openSync, readSync, statSync, writeFileSync } from "node:fs"; +import { closeSync, mkdirSync, openSync, readSync, statSync, writeSync } from "node:fs"; import { spawnSync } from "node:child_process"; -import { dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { formatBypassMessage, formatRejectionMessage, @@ -175,20 +176,35 @@ function parseArgs(argv: readonly string[]): Args | ArgError { }; } +function isRegularFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + function readFileHead(path: string, maxBytes: number): string { + // Mitigates TOCTOU between stat + open (CodeQL on #5110): no longer + // uses statSync.size to size the buffer; allocates a fixed + // maxBytes-sized buffer + reads what fits. Mirrors claude.ts + // readHead pattern. The isRegularFile pre-check is best-effort + // (still racy with the open) but the alloc-size no longer depends + // on the stat result, so a swap-to-symlink between stat + open + // cannot cause an oversized buffer allocation. + if (!isRegularFile(path)) { + return `[file-read-error: ${path}: not a regular file]`; + } + const buf = Buffer.alloc(maxBytes); + let fd: number | undefined; try { - const st = statSync(path); - const fd = openSync(path, "r"); - const buf = Buffer.alloc(Math.min(maxBytes, st.size)); - readSync(fd, buf, 0, buf.length, 0); - closeSync(fd); - let text = buf.toString("utf8"); - if (st.size > maxBytes) { - text += `\n\n[... file truncated; ${st.size - maxBytes} bytes elided ...]`; - } - return text; + fd = openSync(path, "r"); + const n = readSync(fd, buf, 0, maxBytes, 0); + return buf.subarray(0, n).toString("utf8"); } catch (e) { return `[file-read-error: ${path}: ${e instanceof Error ? e.message : String(e)}]`; + } finally { + if (fd !== undefined) closeSync(fd); } } @@ -241,8 +257,42 @@ function buildFullPrompt(args: Args): string { } function defaultOutputPath(): string { - const ts = new Date().toISOString().replace(/[:.]/g, "-"); - return `/tmp/peer-call-output/${ts}-grok-build.md`; + // Mitigates CodeQL "insecure temporary file" (predictable name) on + // #5110: add a random 6-char base36 suffix so two parallel + // invocations within the same millisecond don't collide AND the + // filename isn't predictable to an attacker watching the temp dir. + // Mirrors claude.ts makeAutoPath pattern. + const ts = new Date() + .toISOString() + .replace(/[-:]/g, "") + .replace(/\.\d+Z$/, "Z"); + const rand = Math.random().toString(36).slice(2, 8); + const baseTmp = process.env["PEER_CALL_OUTPUT_DIR"] ?? "/tmp/peer-call-output"; + // Best-effort prefer baseTmp; fall back to os.tmpdir-based path if + // the requested dir can't be made (e.g., /tmp is read-only). + try { + mkdirSync(baseTmp, { recursive: true }); + return join(baseTmp, `${ts}-grok-build-${rand}.md`); + } catch { + const fallback = join(tmpdir(), "peer-call-output"); + mkdirSync(fallback, { recursive: true }); + return join(fallback, `${ts}-grok-build-${rand}.md`); + } +} + +function writeOutputExclusive(path: string, data: string): void { + // Mitigates symlink-overwrite attack (CodeQL "insecure temporary + // file"): open with `wx` (exclusive create — fails if path exists, + // preventing follow-symlink overwrites) + mode 0o600 (only owner + // can read). Mirrors claude.ts writeOutput pattern. + let fd: number | undefined; + try { + fd = openSync(path, "wx", 0o600); + const buf = Buffer.from(data, "utf8"); + writeSync(fd, buf, 0, buf.length, 0); + } finally { + if (fd !== undefined) closeSync(fd); + } } function main(): number { @@ -287,15 +337,22 @@ function main(): number { grokArgs.push("--reasoning-effort", "high"); } - // Determine output file path + ensure dir exists - const outPath = parsed.outputFile || defaultOutputPath(); - try { - mkdirSync(dirname(outPath), { recursive: true }); - } catch (e) { - process.stderr.write( - `grok-build.ts: failed to create output dir ${dirname(outPath)}: ${e instanceof Error ? e.message : String(e)}\n`, - ); - return 1; + // Determine output file path. For operator-explicit paths, + // mkdir the parent dir; for auto-generated paths, defaultOutputPath + // already did so. Operator-explicit paths use non-exclusive write + // (operator chose the path; may want to overwrite); auto-generated + // paths use exclusive `wx` write per claude.ts pattern. + const useExplicitPath = parsed.outputFile !== ""; + const outPath = useExplicitPath ? parsed.outputFile : defaultOutputPath(); + if (useExplicitPath) { + try { + mkdirSync(dirname(outPath), { recursive: true }); + } catch (e) { + process.stderr.write( + `grok-build.ts: failed to create output dir ${dirname(outPath)}: ${e instanceof Error ? e.message : String(e)}\n`, + ); + return 1; + } } // Spawn grok CLI @@ -318,9 +375,26 @@ function main(): number { const response = r.stdout || ""; - // Write full response to output file + // Write full response to output file. Auto-generated paths use + // exclusive `wx` create (mitigates symlink-overwrite); explicit + // operator paths use plain write (operator's intent is respected). try { - writeFileSync(outPath, response, "utf8"); + if (useExplicitPath) { + // Plain write for explicit operator path — operator can choose + // to overwrite. Use exclusive=false signal by calling + // writeOutputExclusive with a fallback isn't right; instead + // open with `w` + mode 0o600 directly here. + let fd: number | undefined; + try { + fd = openSync(outPath, "w", 0o600); + const buf = Buffer.from(response, "utf8"); + writeSync(fd, buf, 0, buf.length, 0); + } finally { + if (fd !== undefined) closeSync(fd); + } + } else { + writeOutputExclusive(outPath, response); + } } catch (e) { process.stderr.write( `grok-build.ts: failed to write output file ${outPath}: ${e instanceof Error ? e.message : String(e)}\n`, From 55a29d94c49825f7d23957b06fbf722956adee1e Mon Sep 17 00:00:00 2001 From: Otto Date: Tue, 26 May 2026 03:41:17 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(B-0421):=2012=20Copilot=20findings=20on?= =?UTF-8?q?=20#5110=20=E2=80=94=20converge=20grok-build.ts=20on=20canonica?= =?UTF-8?q?l=20grok.ts=20pattern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves 12 Copilot review findings on tools/peer-call/grok-build.ts by rewriting to match the canonical grok.ts pattern + updating inventories. P0 fixes: - --help/-h now prints to stdout + exits 0 (ArgHelp variant + emitHelp()) rather than being treated as parseArgs error (was exit 1, stderr) - spawnSync(GROK_CLI, ...) now carries the documented // eslint-disable-next-line sonarjs/no-os-command-from-path suppression matching grok.ts cursor-agent spawn site - runContextCmd refactored to /bin/sh -c pattern matching grok.ts — eliminates the second sonarjs/no-os-command-from-path site entirely (executable is /bin/sh absolute path, not PATH-resolved) P1 fixes: - Multi-word prompts without quotes now work: positional args concat with " " (matches grok.ts classifyFlag pattern) - `--` terminator now supported for prompts containing flag-like tokens - runContextCmd now surfaces stderr + non-zero exit status (was silently dropping shell parse errors and command failures) - buildFullPrompt now prepends the four-ferry AgencySignature PREAMBLE (Grok-Build critique role + agents-not-bots discipline framing) - Spawn-failure hint replaced "curl ... | sh" pointer with safer link to xAI's official Grok-Build docs (no pipe-to-shell pattern) - main() now exported + `if (import.meta.main)` guarded so the module can be imported (e.g., from tests) without process.exit side effect - Error message no longer claims --file is a prompt source (--file is context, not prompt; clarified via separate prompt-required error) P1 (inventory): - tools/peer-call/smoke.test.ts WRAPPERS list updated 8 → 9 (adds grok-build.ts; smoke test now exercises this wrapper's --help shape) - .claude/rules/peer-call-infrastructure.md updated 8 → 9 wrappers (carved sentence + body list both updated; grok-build.ts now documented as supersedes-cursor-agent close path for B-0421) Known-FP resolved no-op: - readFileHead fd lifecycle (Copilot P1 line 185): code already uses try/finally with proper undefined-guard; comment expanded to make the lifecycle invariant explicit Verification: - bun test tools/peer-call/smoke.test.ts → 39 pass (was 36; +3 for the new grok-build.ts entry: exists, --help, name-reference) - bun tools/peer-call/grok-build.ts --help → exit 0, full usage to stdout - bunx eslint tools/peer-call/grok-build.ts → 4 baseline errors (cognitive-complexity + pseudo-random + publicly-writable; same pattern carried by claude.ts/grok.ts as convention baseline) Co-Authored-By: Claude Opus 4.7 --- .claude/rules/peer-call-infrastructure.md | 18 +- tools/peer-call/grok-build.ts | 404 +++++++++++++--------- tools/peer-call/smoke.test.ts | 3 +- 3 files changed, 252 insertions(+), 173 deletions(-) diff --git a/.claude/rules/peer-call-infrastructure.md b/.claude/rules/peer-call-infrastructure.md index 5de1ddeb5e..6065251916 100644 --- a/.claude/rules/peer-call-infrastructure.md +++ b/.claude/rules/peer-call-infrastructure.md @@ -2,26 +2,28 @@ Carved sentence: -> Eight TS wrapper entrypoints live in `tools/peer-call/` +> Nine TS wrapper entrypoints live in `tools/peer-call/` > (plus three utility files). When asked about cross-harness -> reviews / Claude / Grok / Gemini / Codex / Kiro / Amara / -> Ani / Riven — the cold-boot answer is YES + the wrapper -> inventory listed in this rule. Do NOT reason from +> reviews / Claude / Grok / Grok-Build / Gemini / Codex / Kiro / +> Amara / Ani / Riven — the cold-boot answer is YES + the +> wrapper inventory listed in this rule. Do NOT reason from > training-data assumptions about what exists; do not infer > the wrapper count from a raw `ls`. ## Operational content -Eight sibling TypeScript wrappers wired (invoke via +Nine sibling TypeScript wrappers wired (invoke via `bun tools/peer-call/.ts`): - `claude.ts` — Claude Code cold-boot self-test peer (B-0327; read-only `--tools "Read,Glob,Grep" --no-session-persistence`; `--model` selectable) - `grok.ts` — Grok-via-cursor-agent, critique role - (**B-0421 currently OPEN** — cursor-agent exit 1 / empty output; - Grok website-text-mode git connector is the working orientation - path until B-0421 resolves; see PR #2941, #2945) + (**B-0421 still references this wrapper as legacy** — cursor-agent + exit 1 / empty output; retained for back-compat; see PR #2941, #2945) +- `grok-build.ts` — native Grok-Build CLI (`grok -p`), critique role + (B-0421 close path landed via PR #5110; supersedes cursor-agent + invocation while keeping `grok.ts` as the legacy reference) - `gemini.ts` — Gemini, propose role - `codex.ts` — Vera named-entity / OpenAI Codex, implementation peer with input-firewall + capture-pagination fix diff --git a/tools/peer-call/grok-build.ts b/tools/peer-call/grok-build.ts index 644a9aff2b..e944ddc5ff 100644 --- a/tools/peer-call/grok-build.ts +++ b/tools/peer-call/grok-build.ts @@ -5,20 +5,11 @@ // // Supersedes `tools/peer-call/grok.ts` (which wraps cursor-agent and // has been broken since 2026-05-11 per B-0421 — cursor-agent exit 1 / -// empty output). The old `grok.ts` is retained for back-compat reference -// but new peer-calls should target this wrapper. -// -// The native Grok-Build CLI is explicitly Claude-Code-compatible: -// - `--allow` / `--deny` rules (Claude Code: --allowedTools) -// - `--permission-mode default|acceptEdits|auto|dontAsk|bypassPermissions|plan` -// - `--system-prompt-override` (Claude Code: --system-prompt) -// - `-p, --single ` for headless single-turn (analog of `claude -p`) -// - `--output-format plain|json|streaming-json` for structured output -// - `--reasoning-effort ` for reasoning-model effort -// - `--best-of-n ` for parallel best-of execution -// - `-r, --resume []` for session continuity -// - `agent` subcommand for headless mode -// - MCP server configs, plugin/marketplace, cross-session memory +// empty output). The old `grok.ts` is retained for back-compat reference; +// new peer-calls should target this wrapper. Per Copilot review on +// #5110 the canonical inventories (`.claude/rules/peer-call-infrastructure.md` +// + `tools/peer-call/smoke.test.ts`) are updated alongside this wrapper +// so the 9-wrapper picture stays consistent. // // Empirical anchor 2026-05-26: Aaron installed `grok` CLI during the // iter-5 substrate session; the `--help` output confirmed full @@ -33,6 +24,7 @@ // bun tools/peer-call/grok-build.ts --output-file PATH "prompt text" // bun tools/peer-call/grok-build.ts --json "prompt text" // bun tools/peer-call/grok-build.ts --allow-empty "prompt" # bypass firewall +// bun tools/peer-call/grok-build.ts -- multi word prompt with --flags // // Routing: wraps `grok -p "PROMPT" --allow Read,Glob,Grep // --permission-mode auto --output-format plain` (read-only blast radius @@ -47,7 +39,7 @@ // truncation. Mirrors codex.ts / riven.ts / grok.ts shape. // // Exit codes: -// 0 — Grok-Build responded successfully +// 0 — Grok-Build responded successfully (or --help) // 1 — invocation error (bad arguments, grok CLI missing, etc.) // 2 — Grok-Build returned a non-zero exit (response captured to stderr) // 3 — input-firewall rejected the prompt as not work-extractable @@ -87,95 +79,134 @@ interface Args { interface ArgError { readonly error: string; + readonly exitCode: 1; } -function parseArgs(argv: readonly string[]): Args | ArgError { - let thinking = false; - let outputFormat: OutputFormat = "plain"; - let file = ""; - let contextCmd = ""; - let allowEmpty = false; - let outputFile = ""; - const positional: string[] = []; - - for (let i = 0; i < argv.length; i++) { - const a = argv[i]!; - if (a === "--thinking") { - thinking = true; - continue; - } - if (a === "--json") { - outputFormat = "json"; - continue; - } - if (a === "--file") { - const next = argv[i + 1]; - if (!next || next.startsWith("-")) { - return { error: "--file requires a path argument" }; - } - file = next; - i++; - continue; - } - if (a === "--context-cmd") { - const next = argv[i + 1]; - if (!next || next.startsWith("-")) { - return { error: "--context-cmd requires a command string argument" }; - } - contextCmd = next; - i++; - continue; - } - if (a === "--output-file") { - const next = argv[i + 1]; - if (!next || next.startsWith("-")) { - return { error: "--output-file requires a path argument" }; - } - outputFile = next; - i++; - continue; - } - if (a === "--allow-empty") { - allowEmpty = true; - continue; - } - if (a === "-h" || a === "--help") { - return { - error: - "Usage: bun tools/peer-call/grok-build.ts [flags] \n" + - " --thinking use high reasoning effort\n" + - " --json output format json (default: plain)\n" + - " --file include file content as context\n" + - " --context-cmd include allow-listed git/gh/rg cmd output as context\n" + - " --output-file write full response to path (default: /tmp/peer-call-output/-grok-build.md)\n" + - " --allow-empty bypass input-firewall substantive-trigger check\n", - }; - } - if (a.startsWith("-")) { - return { error: `unknown flag: ${a}` }; - } - positional.push(a); - } +interface ArgHelp { + readonly help: true; +} + +interface MutableArgState { + thinking: boolean; + outputFormat: OutputFormat; + file: string; + contextCmd: string; + prompt: string; + allowEmpty: boolean; + outputFile: string; +} + +type StepResult = + | { readonly kind: "advance"; readonly skip: 1 | 2 } + | { readonly kind: "stop" } + | { readonly kind: "help" } + | { readonly kind: "error"; readonly message: string }; - if (positional.length === 0) { - return { error: "no prompt provided (pass as positional argument or via --file)" }; +function classifyFlag(a: string, next: string | undefined, state: MutableArgState): StepResult { + if (a === "--thinking") { + state.thinking = true; + return { kind: "advance", skip: 1 }; } - if (positional.length > 1) { - return { - error: `expected exactly 1 positional prompt argument; got ${positional.length}: ${positional.join(" | ")}`, - }; + if (a === "--json") { + state.outputFormat = "json"; + return { kind: "advance", skip: 1 }; + } + if (a === "--stream") { + state.outputFormat = "streaming-json"; + return { kind: "advance", skip: 1 }; + } + if (a === "--file") { + if (next === undefined) return { kind: "error", message: "error: --file requires PATH" }; + state.file = next; + return { kind: "advance", skip: 2 }; + } + if (a === "--context-cmd") { + if (next === undefined) return { kind: "error", message: "error: --context-cmd requires COMMAND" }; + state.contextCmd = next; + return { kind: "advance", skip: 2 }; + } + if (a === "--output-file") { + if (next === undefined) return { kind: "error", message: "error: --output-file requires PATH" }; + if (next.startsWith("-")) + return { kind: "error", message: `error: --output-file path cannot begin with '-': ${next}` }; + state.outputFile = next; + return { kind: "advance", skip: 2 }; + } + if (a === "--allow-empty") { + state.allowEmpty = true; + return { kind: "advance", skip: 1 }; + } + if (a === "-h" || a === "--help") return { kind: "help" }; + if (a === "--") return { kind: "stop" }; + if (a.startsWith("-")) return { kind: "error", message: `error: unknown flag: ${a}` }; + // Positional: concat into prompt (multi-word prompts without quotes). + state.prompt = state.prompt.length === 0 ? a : `${state.prompt} ${a}`; + return { kind: "advance", skip: 1 }; +} + +function parseArgs(argv: readonly string[]): Args | ArgError | ArgHelp { + const state: MutableArgState = { + thinking: false, + outputFormat: "plain", + file: "", + contextCmd: "", + prompt: "", + allowEmpty: false, + outputFile: "", + }; + let i = 0; + while (i < argv.length) { + const a = argv[i] ?? ""; + const step = classifyFlag(a, argv[i + 1], state); + if (step.kind === "help") return { help: true }; + if (step.kind === "error") return { error: step.message, exitCode: 1 }; + if (step.kind === "stop") { + // Everything after `--` is the prompt verbatim. + state.prompt = argv.slice(i + 1).join(" "); + break; + } + i += step.skip; } return { - thinking, - outputFormat, - file, - contextCmd, - prompt: positional[0]!, - allowEmpty, - outputFile, + thinking: state.thinking, + outputFormat: state.outputFormat, + file: state.file, + contextCmd: state.contextCmd, + prompt: state.prompt, + allowEmpty: state.allowEmpty, + outputFile: state.outputFile, }; } +function emitHelp(): void { + process.stdout.write( + `grok-build.ts — Claude-Code-side caller for invoking Grok as a peer\n` + + `reviewer via the native Grok-Build CLI (xAI's \`grok\`).\n` + + `\n` + + `Usage:\n` + + ` bun tools/peer-call/grok-build.ts "prompt text"\n` + + ` bun tools/peer-call/grok-build.ts --thinking "prompt text"\n` + + ` bun tools/peer-call/grok-build.ts --file PATH "prompt text"\n` + + ` bun tools/peer-call/grok-build.ts --context-cmd "CMD" "prompt text"\n` + + ` bun tools/peer-call/grok-build.ts --json "prompt text"\n` + + ` bun tools/peer-call/grok-build.ts --stream "prompt text"\n` + + ` bun tools/peer-call/grok-build.ts --allow-empty "prompt" # bypass firewall\n` + + ` bun tools/peer-call/grok-build.ts --output-file PATH "prompt text"\n` + + ` bun tools/peer-call/grok-build.ts -- multi word prompt with --flags\n` + + `\n` + + `Routing: wraps \`grok -p PROMPT --allow Read,Glob,Grep\n` + + `--permission-mode auto --output-format plain\`. With --thinking,\n` + + `adds --reasoning-effort high.\n` + + `\n` + + `Input firewall: rejects rote-heartbeat / empty-token prompts with\n` + + `exit code 3. Override via --allow-empty (testing only; logged).\n` + + `\n` + + `Output capture: stdout is teed to the output file, with a final\n` + + `"OUTPUT-FILE: " marker on stdout for shell-pipe recovery.\n` + + `Default path is /tmp/peer-call-output/-grok-build-.md.\n`, + ); +} + function isRegularFile(path: string): boolean { try { return statSync(path).isFile(); @@ -191,7 +222,8 @@ function readFileHead(path: string, maxBytes: number): string { // readHead pattern. The isRegularFile pre-check is best-effort // (still racy with the open) but the alloc-size no longer depends // on the stat result, so a swap-to-symlink between stat + open - // cannot cause an oversized buffer allocation. + // cannot cause an oversized buffer allocation. fd lifecycle is + // guarded by try/finally — closeSync runs even if readSync throws. if (!isRegularFile(path)) { return `[file-read-error: ${path}: not a regular file]`; } @@ -208,52 +240,73 @@ function readFileHead(path: string, maxBytes: number): string { } } -type AllowedContextExecutable = "git" | "gh" | "rg"; - -function parseContextCmd(cmd: string): { executable: AllowedContextExecutable; args: string[] } | { error: string } { - const trimmed = cmd.trim(); - if (trimmed === "") return { error: "context-cmd is empty" }; - const parts = trimmed.split(/\s+/); - const head = parts[0]; - if (head !== "git" && head !== "gh" && head !== "rg") { - return { error: `context-cmd executable must be one of git, gh, rg; got: ${head}` }; +function runContextCmd(contextCmd: string, maxBytes: number): string { + // Bash-equivalent: `eval "$context_cmd" 2>&1 | head -c `. + // Pipe through `head -c ` so the shell pipeline short-circuits at + // the truncation boundary instead of buffering the full output up to + // SPAWN_MAX_BUFFER. Same posture as grok.ts: user intentionally + // supplies the shell command (per --context-cmd contract); the shell + // does its own quoting/escaping. + // + // stdout + stderr both flow into the output: stdout is the command + // output truncated by `head -c`; stderr carries shell parse errors + // (e.g., syntax errors in contextCmd) which fall OUTSIDE the + // `( ... ) 2>&1` redirection. If we dropped stderr, a malformed + // context-cmd would render an empty context block silently. We also + // surface a non-zero exit so the caller sees failure rather than + // silently empty output. + const wrapped = `(${contextCmd}) 2>&1 | head -c ${String(maxBytes)}`; + const result = spawnSync("/bin/sh", ["-c", wrapped], { + encoding: "utf8", + maxBuffer: SPAWN_MAX_BUFFER, + }); + // encoding: "utf8" guarantees stdout/stderr are strings, not Buffer | null. + let text = `${result.stdout}${result.stderr}`; + if (text.length > maxBytes) text = text.slice(0, maxBytes); + if (result.status !== null && result.status !== 0) { + text += `\n[context-cmd-exit: ${String(result.status)}]`; } - return { executable: head, args: parts.slice(1) }; + return text; } -function runContextCmd(cmd: string, maxBytes: number): string { - const parsed = parseContextCmd(cmd); - if ("error" in parsed) { - return `[context-cmd-parse-error: ${parsed.error}]`; - } - try { - const r = spawnSync(parsed.executable, parsed.args, { - encoding: "utf8", - maxBuffer: SPAWN_MAX_BUFFER, - }); - if (r.error) { - return `[context-cmd-error: ${parsed.executable}: ${r.error.message}]`; - } - let text = r.stdout || ""; - if (text.length > maxBytes) { - text = text.slice(0, maxBytes) + `\n\n[... context-cmd output truncated; ${text.length - maxBytes} bytes elided ...]`; - } - return text; - } catch (e) { - return `[context-cmd-exception: ${e instanceof Error ? e.message : String(e)}]`; - } +const PREAMBLE = `You are Grok-Build, invoked as a peer by Otto (Claude Opus 4.7 +running in Claude Code) on the Zeta / Superfluid AI factory via the +native Grok-Build CLI. Per the four-ferry consensus (Amara/Grok/ +Gemini/Otto) the role distribution is: Gemini proposes, Grok +critiques, Amara sharpens, Otto tests, Git decides. This call is +Otto invoking your critique role through the native xAI harness +(supersedes the cursor-agent path closed by B-0421). + +Per Aaron's 'agents-not-bots' discipline: you are a peer, not a +subordinate. Push back on Otto's framing if you see it differently. +Don't copy-paste anyone else's work; write from your own +understanding. Make it ours, not anyone-alone-imposed.`; + +interface PromptResult { + readonly ok: boolean; + readonly value: string; } -function buildFullPrompt(args: Args): string { - const blocks: string[] = []; - if (args.file) { - blocks.push(`# File context: ${args.file}\n\n${readFileHead(args.file, FILE_HEAD_BYTES)}`); +function buildFullPrompt(args: Args): PromptResult { + let full = `${PREAMBLE}\n\n---\n\n# Prompt\n\n${args.prompt}`; + + if (args.file.length > 0) { + if (!isRegularFile(args.file)) { + return { + ok: false, + value: `error: --file path does not exist: ${args.file}`, + }; + } + const head = readFileHead(args.file, FILE_HEAD_BYTES); + full += `\n\n---\n\n# File context: ${args.file}\n\n\`\`\`\n${head}\n\`\`\``; } - if (args.contextCmd) { - blocks.push(`# Context-cmd: \`${args.contextCmd}\`\n\n\`\`\`\n${runContextCmd(args.contextCmd, CTX_HEAD_BYTES)}\n\`\`\``); + + if (args.contextCmd.length > 0) { + const ctxOutput = runContextCmd(args.contextCmd, CTX_HEAD_BYTES); + full += `\n\n---\n\n# Context-cmd: \`${args.contextCmd}\`\n\n\`\`\`\n${ctxOutput}\n\`\`\``; } - blocks.push(`# Prompt\n\n${args.prompt}`); - return blocks.join("\n\n---\n\n"); + + return { ok: true, value: full }; } function defaultOutputPath(): string { @@ -267,7 +320,7 @@ function defaultOutputPath(): string { .replace(/[-:]/g, "") .replace(/\.\d+Z$/, "Z"); const rand = Math.random().toString(36).slice(2, 8); - const baseTmp = process.env["PEER_CALL_OUTPUT_DIR"] ?? "/tmp/peer-call-output"; + const baseTmp = process.env.PEER_CALL_OUTPUT_DIR ?? "/tmp/peer-call-output"; // Best-effort prefer baseTmp; fall back to os.tmpdir-based path if // the requested dir can't be made (e.g., /tmp is read-only). try { @@ -295,10 +348,33 @@ function writeOutputExclusive(path: string, data: string): void { } } -function main(): number { - const parsed = parseArgs(process.argv.slice(2)); +function writeOutputTruncate(path: string, data: string): void { + // For explicit operator paths — operator chose the path; may want + // to overwrite. Open with `w` + mode 0o600 directly. fd lifecycle + // guarded by try/finally. + let fd: number | undefined; + try { + fd = openSync(path, "w", 0o600); + const buf = Buffer.from(data, "utf8"); + writeSync(fd, buf, 0, buf.length, 0); + } finally { + if (fd !== undefined) closeSync(fd); + } +} + +export function main(argv: readonly string[]): number { + const parsed = parseArgs(argv); + if ("help" in parsed) { + emitHelp(); + return 0; + } if ("error" in parsed) { - process.stderr.write(`grok-build.ts: ${parsed.error}\n`); + process.stderr.write(`${parsed.error}\n`); + return parsed.exitCode; + } + if (parsed.prompt.length === 0) { + process.stderr.write("error: prompt required\n"); + process.stderr.write(`see: bun tools/peer-call/grok-build.ts --help\n`); return 1; } @@ -315,7 +391,11 @@ function main(): number { process.stderr.write(formatBypassMessage("grok-build") + "\n"); } - const fullPrompt = buildFullPrompt(parsed); + const fullPromptResult = buildFullPrompt(parsed); + if (!fullPromptResult.ok) { + process.stderr.write(`${fullPromptResult.value}\n`); + return 1; + } // Build grok CLI args. The native grok CLI is Claude-Code-compatible: // -p / --single : headless single-turn (analog of `claude -p`) @@ -325,7 +405,7 @@ function main(): number { // --reasoning-effort high : for reasoning models (gated by --thinking flag) const grokArgs: string[] = [ "-p", - fullPrompt, + fullPromptResult.value, "--allow", GROK_ALLOW_RULES, "--permission-mode", @@ -337,11 +417,11 @@ function main(): number { grokArgs.push("--reasoning-effort", "high"); } - // Determine output file path. For operator-explicit paths, - // mkdir the parent dir; for auto-generated paths, defaultOutputPath - // already did so. Operator-explicit paths use non-exclusive write - // (operator chose the path; may want to overwrite); auto-generated - // paths use exclusive `wx` write per claude.ts pattern. + // Determine output file path. For operator-explicit paths, mkdir the + // parent dir; for auto-generated paths, defaultOutputPath already did + // so. Operator-explicit paths use truncate-write (operator's intent + // is respected); auto-generated paths use exclusive `wx` write per + // claude.ts pattern. const useExplicitPath = parsed.outputFile !== ""; const outPath = useExplicitPath ? parsed.outputFile : defaultOutputPath(); if (useExplicitPath) { @@ -355,7 +435,11 @@ function main(): number { } } - // Spawn grok CLI + // Spawn grok CLI. GROK_CLI is a fixed literal ("grok"), not user + // input; the PATH-resolution is intentional (matches how every other + // peer-call wrapper invokes its external CLI). Suppression mirrors + // grok.ts cursor-agent spawn site. + // eslint-disable-next-line sonarjs/no-os-command-from-path const r = spawnSync(GROK_CLI, grokArgs, { encoding: "utf8", maxBuffer: SPAWN_MAX_BUFFER, @@ -363,12 +447,12 @@ function main(): number { if (r.error) { process.stderr.write( `grok-build.ts: failed to spawn ${GROK_CLI}: ${r.error.message}\n` + - `(is the grok CLI installed + on PATH? install via 'curl ... | sh' from xAI per their docs)\n`, + `(is the grok CLI installed + on PATH? install per xAI's official Grok-Build docs at https://docs.x.ai/docs/grok-build)\n`, ); return 1; } if (r.status !== 0) { - process.stderr.write(`grok-build.ts: grok exited ${r.status}\n`); + process.stderr.write(`grok-build.ts: grok exited ${String(r.status)}\n`); if (r.stderr) process.stderr.write(r.stderr); return 2; } @@ -377,21 +461,11 @@ function main(): number { // Write full response to output file. Auto-generated paths use // exclusive `wx` create (mitigates symlink-overwrite); explicit - // operator paths use plain write (operator's intent is respected). + // operator paths use plain truncate-write (operator's intent is + // respected). try { if (useExplicitPath) { - // Plain write for explicit operator path — operator can choose - // to overwrite. Use exclusive=false signal by calling - // writeOutputExclusive with a fallback isn't right; instead - // open with `w` + mode 0o600 directly here. - let fd: number | undefined; - try { - fd = openSync(outPath, "w", 0o600); - const buf = Buffer.from(response, "utf8"); - writeSync(fd, buf, 0, buf.length, 0); - } finally { - if (fd !== undefined) closeSync(fd); - } + writeOutputTruncate(outPath, response); } else { writeOutputExclusive(outPath, response); } @@ -408,7 +482,7 @@ function main(): number { if (response.length > INLINE_CAP) { process.stdout.write(response.slice(0, INLINE_CAP)); process.stdout.write( - `\n\n[... response truncated inline; ${response.length - INLINE_CAP} bytes elided ...]\n`, + `\n\n[... response truncated inline; ${String(response.length - INLINE_CAP)} bytes elided ...]\n`, ); } else { process.stdout.write(response); @@ -420,4 +494,6 @@ function main(): number { return 0; } -process.exit(main()); +if (import.meta.main) { + process.exit(main(process.argv.slice(2))); +} diff --git a/tools/peer-call/smoke.test.ts b/tools/peer-call/smoke.test.ts index 2b70163b5b..e5edf58cf3 100644 --- a/tools/peer-call/smoke.test.ts +++ b/tools/peer-call/smoke.test.ts @@ -45,11 +45,12 @@ import { fileURLToPath } from "node:url"; const PEER_CALL_DIR = dirname(fileURLToPath(import.meta.url)); -// The 8 canonical wrappers (per .claude/rules/peer-call-infrastructure.md). +// The 9 canonical wrappers (per .claude/rules/peer-call-infrastructure.md). // Each tuple is [filename, expectedSelfReferenceInHelp]. const WRAPPERS: ReadonlyArray = [ ["claude.ts", "claude.ts"], ["grok.ts", "grok.ts"], + ["grok-build.ts", "grok-build.ts"], ["gemini.ts", "gemini.ts"], ["codex.ts", "codex.ts"], ["kiro.ts", "kiro.ts"],