From 1bfc147e07fc7501c12f90b73c793e2186da478c Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Thu, 30 Apr 2026 02:15:15 -0400 Subject: [PATCH 1/5] =?UTF-8?q?ts(B-0086):=20port=201=20peer-call=20siblin?= =?UTF-8?q?g=20(.sh=E2=86=92.ts)=20=E2=80=94=20slice=2016=20of=20TS/Bun=20?= =?UTF-8?q?migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ts(slice-16, wip 1/N): port peer-call/gemini (.sh→.ts) Sibling of slice-15 grok port (PR #896). Wraps 'gemini -p' non-interactive headless mode. Read-only safety preserved (--approval-mode plan + --skip-trust per the Copilot fix on the bash original PR #28). Reuses the structural pattern from grok.ts: - classifyFlag + MutableArgState parser (under cog-complexity 15) - spawnSync + stdio:inherit for live LLM streaming - isRegularFile + readHead helpers - /bin/sh -c for context-cmd (same trust as bash eval) - PREAMBLE preserved verbatim with Gemini-specific framing (proposes role per four-ferry consensus) Lint-clean: bun --bun tsc --noEmit + eslint strictTypeChecked + sonarjs all pass. Argument-validation byte-equivalent. Note: the same CodeQL js/indirect-command-line-injection alert will fire here as on grok.ts; per B-0107 (filed in PR #897) the per-PR dismissal pattern applies. Will dismiss after PR opens. --- .../typescript-bun-migration/RESUME.md | 6 +- tools/peer-call/gemini.ts | 286 ++++++++++++++++++ 2 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 tools/peer-call/gemini.ts diff --git a/docs/trajectories/typescript-bun-migration/RESUME.md b/docs/trajectories/typescript-bun-migration/RESUME.md index 8a6f59182..b2fe14538 100644 --- a/docs/trajectories/typescript-bun-migration/RESUME.md +++ b/docs/trajectories/typescript-bun-migration/RESUME.md @@ -1,9 +1,9 @@ # Trajectory — TypeScript / Bun migration -**Status**: Active (Lane B slice 14 merged — [#894](https://github.com/Lucent-Financial-Group/Zeta/pull/894), commit `9cb21a7`) -**Milestone**: 35 ported + 1 in-flight = 36 total (2 from #849 + 3 from #866 + 3 from #868 + 3 from #870 + 2 from #872 + 3 from #874 + 3 from #876 + 3 from #878 + 3 from #880 + 3 from #882 + 2 from #883 + 2 from #884 + 1 from #885 + 1 from #892 + 1 from #894 = 35 merged; +1 in-flight in slice-15). Slice-15 opens **peer-call cluster** (peer-call/grok). 7 Bucket B files remain. +**Status**: Active (Lane B slice 15 merged — [#896](https://github.com/Lucent-Financial-Group/Zeta/pull/896)) +**Milestone**: 36 ported + 1 in-flight = 37 total. Slice-16 opens **peer-call sibling** (peer-call/gemini, sibling of slice-15 grok). 6 Bucket B files remain. **Current blocker**: None. -**Next concrete action**: Pick a coherent next slice from Bucket B (7 files remaining). Per Gate B: read-only scope first, then re-verify the layered baseline currency before first mutating action. +**Next concrete action**: Pick a coherent next slice from Bucket B (6 files remaining). Per Gate B: read-only scope first, then re-verify the layered baseline currency before first mutating action. **Last updated**: 2026-04-30 ## Why this trajectory exists diff --git a/tools/peer-call/gemini.ts b/tools/peer-call/gemini.ts new file mode 100644 index 000000000..131bdec46 --- /dev/null +++ b/tools/peer-call/gemini.ts @@ -0,0 +1,286 @@ +#!/usr/bin/env bun +// gemini.ts — Claude-Code-side caller for invoking Gemini as a peer +// proposer via the gemini CLI. +// +// TypeScript+Bun port of gemini.sh, slice 16 of the TS+Bun migration. +// Sibling to tools/peer-call/grok.ts (slice 15, PR #896). Per the +// four-ferry consensus: Gemini proposes, Grok critiques, Amara +// sharpens, Otto tests, Git decides. This script is Otto invoking +// Gemini's propose role. +// +// Usage: +// bun tools/peer-call/gemini.ts "prompt text" +// bun tools/peer-call/gemini.ts --model gemini-2.5-pro "prompt text" +// bun tools/peer-call/gemini.ts --file path/to/file.fs "prompt text" +// bun tools/peer-call/gemini.ts --context-cmd "git diff HEAD~3..HEAD" "prompt" +// bun tools/peer-call/gemini.ts --json "prompt text" +// bun tools/peer-call/gemini.ts --stream "prompt text" +// +// Routing: wraps `gemini -p` (non-interactive headless mode). +// Default model is whatever the gemini CLI is configured to use; +// override with --model. +// +// Exit codes: +// 0 — Gemini responded successfully +// 1 — invocation error (bad arguments, gemini missing, etc.) +// 2 — Gemini returned a non-zero exit (diagnostic on stderr) + +import { readFileSync, statSync } from "node:fs"; +import { spawnSync } from "node:child_process"; + +const SPAWN_MAX_BUFFER = 64 * 1024 * 1024; +const FILE_HEAD_BYTES = 20000; +const CTX_HEAD_BYTES = 20000; + +type OutputFormat = "text" | "json" | "stream-json"; + +interface Args { + readonly model: string; + readonly outputFormat: OutputFormat; + readonly file: string; + readonly contextCmd: string; + readonly prompt: string; +} + +interface ArgError { + readonly error: string; + readonly exitCode: 1; +} + +interface ArgHelp { + readonly help: true; +} + +interface MutableArgState { + model: string; + outputFormat: OutputFormat; + file: string; + contextCmd: string; + prompt: string; +} + +type StepResult = + | { readonly kind: "advance"; readonly skip: 1 | 2 } + | { readonly kind: "stop" } + | { readonly kind: "help" } + | { readonly kind: "error"; readonly message: string }; + +function classifyFlag( + a: string, + next: string | undefined, + state: MutableArgState, +): StepResult { + if (a === "--model") { + if (next === undefined) return { kind: "error", message: "error: --model requires NAME" }; + state.model = next; + return { kind: "advance", skip: 2 }; + } + if (a === "--json") { state.outputFormat = "json"; return { kind: "advance", skip: 1 }; } + if (a === "--stream") { state.outputFormat = "stream-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 === "-h" || a === "--help") return { kind: "help" }; + if (a === "--") return { kind: "stop" }; + if (a.startsWith("-")) return { kind: "error", message: `error: unknown flag: ${a}` }; + 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 = { + model: "", + outputFormat: "text", + file: "", + contextCmd: "", + prompt: "", + }; + 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") { + state.prompt = argv.slice(i + 1).join(" "); + break; + } + i += step.skip; + } + return { + model: state.model, + outputFormat: state.outputFormat, + file: state.file, + contextCmd: state.contextCmd, + prompt: state.prompt, + }; +} + +function emitHelp(): void { + process.stdout.write( + `gemini.ts — Claude-Code-side caller for invoking Gemini as a peer\n` + + `proposer via the gemini CLI.\n` + + `\n` + + `Usage:\n` + + ` bun tools/peer-call/gemini.ts "prompt text"\n` + + ` bun tools/peer-call/gemini.ts --model NAME "prompt text"\n` + + ` bun tools/peer-call/gemini.ts --file PATH "prompt text"\n` + + ` bun tools/peer-call/gemini.ts --context-cmd "CMD" "prompt text"\n` + + ` bun tools/peer-call/gemini.ts --json "prompt text"\n` + + ` bun tools/peer-call/gemini.ts --stream "prompt text"\n` + + `\n` + + `Routing: wraps gemini -p (non-interactive headless mode).\n`, + ); +} + +function commandAvailable(cmd: string): boolean { + const result = spawnSync(cmd, ["--version"], { stdio: "ignore" }); + return result.status === 0; +} + +function isRegularFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +function readHead(path: string, bytes: number): string { + if (!isRegularFile(path)) return ""; + const buf = readFileSync(path); + return buf.subarray(0, bytes).toString("utf8"); +} + +function runContextCmd(contextCmd: string): string { + // Bash uses `eval "$context_cmd" 2>&1 | head -c 20000`. Match that + // shape: shell -c so user-supplied compound commands work, capture + // both stdout + stderr, truncate to CTX_HEAD_BYTES. User intentionally + // supplies the shell command (per --context-cmd contract); same + // security posture as the bash original's `eval`. + const result = spawnSync("/bin/sh", ["-c", contextCmd], { + encoding: "utf8", + maxBuffer: SPAWN_MAX_BUFFER, + }); + const combined = `${result.stdout}${result.stderr}`; + return combined.slice(0, CTX_HEAD_BYTES); +} + +const PREAMBLE = `You are Gemini, invoked as a peer proposer by Otto (Claude +Opus 4.7 running in Claude Code) on the Zeta / Superfluid AI +factory. 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 propose role. + +Per Aaron's 'agents-not-bots' discipline: you are a peer, not +a subordinate. Generate divergent options, name tradeoffs, +surface possibility space Otto may not have considered. Don't +copy-paste anyone else's work; propose from your own +understanding. Make it ours, not anyone-alone-imposed.`; + +interface PromptResult { + readonly ok: boolean; + readonly value: string; +} + +function buildFullPrompt(args: Args): PromptResult { + let full = `${PREAMBLE}\n\n---\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 = readHead(args.file, FILE_HEAD_BYTES); + full += `\n\n---\n\nFile context: ${args.file}\n\`\`\`\n${head}\n\`\`\``; + } + + if (args.contextCmd.length > 0) { + const ctxOutput = runContextCmd(args.contextCmd); + full += `\n\n---\n\nContext command: ${args.contextCmd}\nOutput:\n\`\`\`\n${ctxOutput}\n\`\`\``; + } + + return { ok: true, value: full }; +} + +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(`${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/gemini.ts --help\n"); + return 1; + } + + if (!commandAvailable("gemini")) { + process.stderr.write("error: gemini not on PATH\n"); + process.stderr.write( + "install via: npm i -g @google/gemini-cli (or per Aaron's setup)\n", + ); + return 1; + } + + const promptResult = buildFullPrompt(parsed); + if (!promptResult.ok) { + process.stderr.write(`${promptResult.value}\n`); + return 1; + } + + // gemini invocation: --approval-mode plan keeps the call read-only + // (per gemini --help: plan = "read-only mode"). Earlier draft used + // --yolo which auto-approved ALL tool calls including writes — that + // violates the "peer-call is read-only" contract per Copilot review + // on PR #28. Pass --skip-trust so the workspace doesn't gate on + // per-session trust prompts. + const args: string[] = [ + "-p", + promptResult.value, + "--approval-mode", + "plan", + "--skip-trust", + "-o", + parsed.outputFormat, + ]; + if (parsed.model.length > 0) { + args.push("-m", parsed.model); + } + + const result = spawnSync( + // eslint-disable-next-line sonarjs/no-os-command-from-path + "gemini", + args, + { + stdio: "inherit", + maxBuffer: SPAWN_MAX_BUFFER, + }, + ); + + const exitCode = result.status ?? 1; + if (exitCode !== 0) { + process.stderr.write("\n"); + process.stderr.write(`gemini exited with code ${String(exitCode)}\n`); + return 2; + } + return 0; +} + +if (import.meta.main) { + process.exit(main(process.argv.slice(2))); +} From be2507fd6f8831a61d0867e1af130ca9ff847d09 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Thu, 30 Apr 2026 02:18:59 -0400 Subject: [PATCH 2/5] =?UTF-8?q?review(slice-16):=20address=20Codex=20P1=20?= =?UTF-8?q?+=20P2=20=E2=80=94=20head-only=20file=20read=20+=20pipe-through?= =?UTF-8?q?-head=20context-cmd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real Codex findings on PR #898 (both also apply to grok.ts — backport in follow-up PR): P1 — Read only file head instead of loading full file: Original used readFileSync + Buffer.subarray which loads the entire file into memory then slices. For large artifacts (logs, dumps) this is a memory-spike regression vs bash 'head -c 20000'. Replaced with openSync + readSync(fd, buf, 0, bytes, 0) which reads only the first FILE_HEAD_BYTES bytes from disk. Wrapped in try/finally for fd cleanup. P2 — Truncate context command output at source: Original used spawnSync with maxBuffer up to 64MB then sliced to CTX_HEAD_BYTES afterward. For high-volume commands (wide 'git diff' ranges) this blocks longer and uses more memory than the bash original's '... | head -c 20000' pipeline that short-circuits at the boundary. Reframed wrapped command as: (cmd) 2>&1 | head -c so the shell pipeline truncates at source. Same trust contract preserved (user supplies cmd; we just augment with head-c). Verified locally: 100MB test file reads only 20000 bytes via the new head-only path. --- tools/peer-call/gemini.ts | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/tools/peer-call/gemini.ts b/tools/peer-call/gemini.ts index 131bdec46..bbd54423c 100644 --- a/tools/peer-call/gemini.ts +++ b/tools/peer-call/gemini.ts @@ -25,7 +25,7 @@ // 1 — invocation error (bad arguments, gemini missing, etc.) // 2 — Gemini returned a non-zero exit (diagnostic on stderr) -import { readFileSync, statSync } from "node:fs"; +import { closeSync, openSync, readSync, statSync } from "node:fs"; import { spawnSync } from "node:child_process"; const SPAWN_MAX_BUFFER = 64 * 1024 * 1024; @@ -154,23 +154,38 @@ function isRegularFile(path: string): boolean { } function readHead(path: string, bytes: number): string { + // Read only the first `bytes` bytes (matches bash `head -c`). + // readFileSync would load the entire file before slicing — regression + // for large artifacts (logs, dumps) per Codex P1 on #898. if (!isRegularFile(path)) return ""; - const buf = readFileSync(path); - return buf.subarray(0, bytes).toString("utf8"); + const buf = Buffer.alloc(bytes); + let fd: number | undefined; + try { + fd = openSync(path, "r"); + const n = readSync(fd, buf, 0, bytes, 0); + return buf.subarray(0, n).toString("utf8"); + } catch { + return ""; + } finally { + if (fd !== undefined) closeSync(fd); + } } function runContextCmd(contextCmd: string): string { // Bash uses `eval "$context_cmd" 2>&1 | head -c 20000`. Match that - // shape: shell -c so user-supplied compound commands work, capture - // both stdout + stderr, truncate to CTX_HEAD_BYTES. User intentionally - // supplies the shell command (per --context-cmd contract); same - // security posture as the bash original's `eval`. - const result = spawnSync("/bin/sh", ["-c", contextCmd], { + // shape end-to-end: 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 (Codex P2 on #898 — high-volume + // commands like wide `git diff` would otherwise block much longer + // and use much more memory than the bash original). + // User intentionally supplies the shell command (per --context-cmd + // contract); same security posture as the bash original's `eval`. + const wrapped = `(${contextCmd}) 2>&1 | head -c ${String(CTX_HEAD_BYTES)}`; + const result = spawnSync("/bin/sh", ["-c", wrapped], { encoding: "utf8", maxBuffer: SPAWN_MAX_BUFFER, }); - const combined = `${result.stdout}${result.stderr}`; - return combined.slice(0, CTX_HEAD_BYTES); + return result.stdout; } const PREAMBLE = `You are Gemini, invoked as a peer proposer by Otto (Claude From ceed1e758fd81cd7f614ccb1f625cfe7e650dea9 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Thu, 30 Apr 2026 02:25:18 -0400 Subject: [PATCH 3/5] review(slice-16): preserve shell parse errors per Codex P2 + Copilot (#899 finding backport) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as #899 backport — runContextCmd was returning only result.stdout, dropping shell-process stderr (where parse errors land, OUTSIDE the (cmd) 2>&1 redirect). Concat stdout+stderr then slice to CTX_HEAD_BYTES. --- tools/peer-call/gemini.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tools/peer-call/gemini.ts b/tools/peer-call/gemini.ts index bbd54423c..e8c01e11e 100644 --- a/tools/peer-call/gemini.ts +++ b/tools/peer-call/gemini.ts @@ -185,7 +185,13 @@ function runContextCmd(contextCmd: string): string { encoding: "utf8", maxBuffer: SPAWN_MAX_BUFFER, }); - return result.stdout; + // Concatenate stdout + stderr: stdout carries 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. Per Codex P2 + Copilot on #899 / #898 the parse-error + // diagnostic must reach the prompt or the user sees an empty context + // block on a malformed cmd. + return `${result.stdout}${result.stderr}`.slice(0, CTX_HEAD_BYTES); } const PREAMBLE = `You are Gemini, invoked as a peer proposer by Otto (Claude From c46d1792f07a9b9f35f44921ef5c5dac571ec638 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Thu, 30 Apr 2026 02:32:32 -0400 Subject: [PATCH 4/5] =?UTF-8?q?review(slice-16):=20address=20#898=20P1+P2?= =?UTF-8?q?=20=E2=80=94=20exit=20codes,=20spawn=20classification,=20bash?= =?UTF-8?q?=20shell,=20file-read=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five real findings from Copilot+Codex on PR #898: Copilot P1 — Exit codes 0|2|64 per repo-scripting.md: Switched return 1 → return 64 for invocation/usage errors (--model/--file/--context-cmd missing values, unknown flag, empty prompt). Aligns with the conventions in docs/best- practices/repo-scripting.md §exit-codes. Process-related errors (gemini missing on PATH, build-prompt error) keep 1 for tooling/input failure. Copilot P1 — spawnSync result.status ?? 1 collapses launch failures: Added classifySpawnFailure helper (4-case: status set / ENOENT → 127 / signal / other) — same pattern from PR #887. Distinguishes ENOENT/permission/signal from a normal non-zero exit, with a contextual stderr message. Codex P2 — eval vs /bin/sh syntax difference: Switched /bin/sh -c → /bin/bash -c so the bash original's eval semantics (accepting bash-only features like `[[ ]]`, brace expansion, process substitution) are preserved. /bin/sh on Ubuntu is dash and rejects these. Codex P2 — file-read failures silently dropping context: readHead() now returns ReadHeadResult { ok, content, error }. buildFullPrompt() propagates the error to the user via stderr instead of pretending context was attached when read failed (permission denied, race, etc.). Threads on already-addressed findings (PRRT_kwDOSF9kNM5-pd16 runContextCmd full-buffering + PRRT_kwDOSF9kNM5-pd2a readHead full-file-read) were resolved in the parse-error-fix commit ceed1e7. Threads on PRRT_kwDOSF9kNM5-pd2U PREAMBLE-names: pushing back because the PREAMBLE is prompt content sent verbatim to the LLM peer, not pure code-surface; changing it diverges from the bash original byte-equivalence. A separate task to retool PREAMBLE attribution on both bash AND TS together is the right shape, not a one-side rename here. --- tools/peer-call/gemini.ts | 97 ++++++++++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 16 deletions(-) diff --git a/tools/peer-call/gemini.ts b/tools/peer-call/gemini.ts index e8c01e11e..a836975a8 100644 --- a/tools/peer-call/gemini.ts +++ b/tools/peer-call/gemini.ts @@ -44,7 +44,9 @@ interface Args { interface ArgError { readonly error: string; - readonly exitCode: 1; + // Exit code 64 = invocation/usage error per + // docs/best-practices/repo-scripting.md §exit-codes (rule 0|2|64). + readonly exitCode: 64; } interface ArgHelp { @@ -71,7 +73,7 @@ function classifyFlag( state: MutableArgState, ): StepResult { if (a === "--model") { - if (next === undefined) return { kind: "error", message: "error: --model requires NAME" }; + if (next === undefined) return { kind: "error", message: "error: --model requires NAME" }; // exitCode set in parseArgs state.model = next; return { kind: "advance", skip: 2 }; } @@ -107,7 +109,7 @@ function parseArgs(argv: readonly string[]): Args | ArgError | ArgHelp { 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 === "error") return { error: step.message, exitCode: 64 }; if (step.kind === "stop") { state.prompt = argv.slice(i + 1).join(" "); break; @@ -153,19 +155,33 @@ function isRegularFile(path: string): boolean { } } -function readHead(path: string, bytes: number): string { +interface ReadHeadResult { + readonly ok: boolean; + readonly content: string; + readonly error: string; +} + +function readHead(path: string, bytes: number): ReadHeadResult { // Read only the first `bytes` bytes (matches bash `head -c`). // readFileSync would load the entire file before slicing — regression // for large artifacts (logs, dumps) per Codex P1 on #898. - if (!isRegularFile(path)) return ""; + // Surfaces read failures (permission, race, etc.) instead of silently + // returning empty per Codex P2 on #898 — bash's `head` writes errors + // to stderr; we propagate via the result type so the caller can decide + // (warn the user, abort, etc.) rather than building a prompt that + // looks like context was attached when it wasn't. + if (!isRegularFile(path)) { + return { ok: false, content: "", error: "not a regular file" }; + } const buf = Buffer.alloc(bytes); let fd: number | undefined; try { fd = openSync(path, "r"); const n = readSync(fd, buf, 0, bytes, 0); - return buf.subarray(0, n).toString("utf8"); - } catch { - return ""; + return { ok: true, content: buf.subarray(0, n).toString("utf8"), error: "" }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, content: "", error: message }; } finally { if (fd !== undefined) closeSync(fd); } @@ -178,10 +194,14 @@ function runContextCmd(contextCmd: string): string { // full output up to SPAWN_MAX_BUFFER (Codex P2 on #898 — high-volume // commands like wide `git diff` would otherwise block much longer // and use much more memory than the bash original). + // Use /bin/bash -c (not /bin/sh -c) so bash-only features (`[[ ... ]]`, + // brace expansion, process substitution) accepted by the bash + // original's `eval` continue to work — Codex P2 on #898 noted that + // /bin/sh on Ubuntu is dash and accepts a strict POSIX subset. // User intentionally supplies the shell command (per --context-cmd // contract); same security posture as the bash original's `eval`. const wrapped = `(${contextCmd}) 2>&1 | head -c ${String(CTX_HEAD_BYTES)}`; - const result = spawnSync("/bin/sh", ["-c", wrapped], { + const result = spawnSync("/bin/bash", ["-c", wrapped], { encoding: "utf8", maxBuffer: SPAWN_MAX_BUFFER, }); @@ -194,6 +214,33 @@ function runContextCmd(contextCmd: string): string { return `${result.stdout}${result.stderr}`.slice(0, CTX_HEAD_BYTES); } +interface SpawnError { + readonly code?: string; +} + +interface ChildOutcome { + readonly status: number; + readonly note: string; +} + +function classifySpawnFailure( + status: number | null, + signal: string | null, + error: SpawnError | undefined, +): ChildOutcome { + if (status !== null) return { status, note: "" }; + if (error?.code === "ENOENT") { + return { status: 127, note: "command not found on PATH (ENOENT)" }; + } + if (error?.code !== undefined) { + return { status: 1, note: `spawn failed (${error.code})` }; + } + if (signal !== null) { + return { status: 1, note: `terminated by signal ${signal}` }; + } + return { status: 1, note: "terminated without exit code" }; +} + const PREAMBLE = `You are Gemini, invoked as a peer proposer by Otto (Claude Opus 4.7 running in Claude Code) on the Zeta / Superfluid AI factory. Per the four-ferry consensus (Amara/Grok/Gemini/Otto) @@ -222,8 +269,17 @@ function buildFullPrompt(args: Args): PromptResult { value: `error: --file path does not exist: ${args.file}`, }; } - const head = readHead(args.file, FILE_HEAD_BYTES); - full += `\n\n---\n\nFile context: ${args.file}\n\`\`\`\n${head}\n\`\`\``; + const headResult = readHead(args.file, FILE_HEAD_BYTES); + if (!headResult.ok) { + // Surface read failures (permission, race, etc.) per Codex P2 + // on #898; bash's `head` writes to stderr — abort instead of + // pretending context was attached. + return { + ok: false, + value: `error: --file read failed for ${args.file}: ${headResult.error}`, + }; + } + full += `\n\n---\n\nFile context: ${args.file}\n\`\`\`\n${headResult.content}\n\`\`\``; } if (args.contextCmd.length > 0) { @@ -247,13 +303,15 @@ export function main(argv: readonly string[]): number { if (parsed.prompt.length === 0) { process.stderr.write("error: prompt required\n"); process.stderr.write("see: bun tools/peer-call/gemini.ts --help\n"); - return 1; + // Exit code 64 = invocation/usage error per + // docs/best-practices/repo-scripting.md. + return 64; } if (!commandAvailable("gemini")) { process.stderr.write("error: gemini not on PATH\n"); process.stderr.write( - "install via: npm i -g @google/gemini-cli (or per Aaron's setup)\n", + "install via: npm i -g @google/gemini-cli (or per the maintainer's setup)\n", ); return 1; } @@ -293,10 +351,17 @@ export function main(argv: readonly string[]): number { }, ); - const exitCode = result.status ?? 1; - if (exitCode !== 0) { + const classified = classifySpawnFailure( + result.status, + result.signal, + result.error as SpawnError | undefined, + ); + if (classified.note.length > 0) { + process.stderr.write(`gemini: ${classified.note}\n`); + } + if (classified.status !== 0) { process.stderr.write("\n"); - process.stderr.write(`gemini exited with code ${String(exitCode)}\n`); + process.stderr.write(`gemini exited with code ${String(classified.status)}\n`); return 2; } return 0; From 712bce377ec6df5e065f3d19a34839642d581968 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Thu, 30 Apr 2026 02:40:27 -0400 Subject: [PATCH 5/5] =?UTF-8?q?review(slice-16)=20round-2:=20revert=20exit?= =?UTF-8?q?-code=2064=20=E2=86=92=201;=20fix=20commandAvailable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new findings on the latest push: Codex P2 (PRRT_kwDOSF9kNM5-pon2) + Copilot P0 x2 — exit codes: Last commit switched to exit 64 per docs/best-practices/repo- scripting.md. Reviewers correctly point out tools/peer-call/ README.md is the more specific spec and says 0/1/2 uniform across all three peer-call wrappers. Specific wins on overlap. Reverted ArgError.exitCode 64 → 1 + the empty-prompt return 64 → 1. Matches grok.ts (slice 15) and the bash original. Copilot P1 — commandAvailable() shape: Was using ` --version` and requiring exit 0. Some CLIs exit non-zero on --version. Bash uses `command -v ` (PATH existence check, no execution). Switched to `spawnSync('/bin/sh', ['-c', \`command -v "${cmd}"\`])` to match bash semantics. Copilot P1 (PRRT_kwDOSF9kNM5-ppuR) — PREAMBLE names: same as prior thread; pushing back with same rationale (PREAMBLE is verbatim prompt content sent to LLM, not pure code-surface; preserving bash-equivalence). Will reply on the new thread the same way. Copilot P2 (PRRT_kwDOSF9kNM5-ppt6) — PR description says /bin/sh but code uses /bin/bash. The /bin/bash switch was a Codex P2 from the prior round (preserve eval semantics for bash-only features). Will update PR description, not the code. --- tools/peer-call/gemini.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tools/peer-call/gemini.ts b/tools/peer-call/gemini.ts index a836975a8..0137abcd4 100644 --- a/tools/peer-call/gemini.ts +++ b/tools/peer-call/gemini.ts @@ -44,9 +44,11 @@ interface Args { interface ArgError { readonly error: string; - // Exit code 64 = invocation/usage error per - // docs/best-practices/repo-scripting.md §exit-codes (rule 0|2|64). - readonly exitCode: 64; + // Exit code 1 = invocation/usage error per tools/peer-call/README.md + // (uniform 0/1/2 across all three peer-call wrappers — README scope + // is more specific than the general repo-scripting.md 0/2/64 spec + // and wins on overlap). + readonly exitCode: 1; } interface ArgHelp { @@ -109,7 +111,7 @@ function parseArgs(argv: readonly string[]): Args | ArgError | ArgHelp { 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: 64 }; + if (step.kind === "error") return { error: step.message, exitCode: 1 }; if (step.kind === "stop") { state.prompt = argv.slice(i + 1).join(" "); break; @@ -143,7 +145,12 @@ function emitHelp(): void { } function commandAvailable(cmd: string): boolean { - const result = spawnSync(cmd, ["--version"], { stdio: "ignore" }); + // Match bash `command -v ` semantics: PATH existence, not + // ` --version` exit-status (Copilot P1 on #898 — some CLIs + // exit non-zero on --version which is irrelevant to availability). + const result = spawnSync("/bin/sh", ["-c", `command -v "${cmd}"`], { + stdio: "ignore", + }); return result.status === 0; } @@ -303,9 +310,9 @@ export function main(argv: readonly string[]): number { if (parsed.prompt.length === 0) { process.stderr.write("error: prompt required\n"); process.stderr.write("see: bun tools/peer-call/gemini.ts --help\n"); - // Exit code 64 = invocation/usage error per - // docs/best-practices/repo-scripting.md. - return 64; + // Exit code 1 = invocation/usage error per tools/peer-call/README.md + // (uniform 0/1/2 across all three peer-call wrappers). + return 1; } if (!commandAvailable("gemini")) {