run-all.sh 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. #!/usr/bin/env bash
  2. # With/without A/B (and optional interactive) eval for a codegraph version on a
  3. # repo. Codegraph is the ONLY variable: both arms launch claude with
  4. # --strict-mcp-config — with = codegraph-only MCP (pointed at $CG_BIN),
  5. # without = empty MCP. Built-in Read/Grep/Bash stay available in both arms.
  6. #
  7. # Usage: run-all.sh <repo-path> "<question>" [headless|tmux|all]
  8. #
  9. # MULTI-TURN: separate questions with "||" to run them as ONE session —
  10. # run-all.sh <repo> "How does X work?||Where is Y handled in that path?"
  11. # Turn 1 runs normally; every later turn `--resume`s the same session, so the
  12. # earlier turns' tool output is still in the window (that is the whole point:
  13. # residual context occupancy, the cost a single-question run cannot see).
  14. # Segments land in run-<label>.jsonl, run-<label>.t2.jsonl, … and parse-run.mjs
  15. # stitches them back into one session.
  16. #
  17. # Env: CG_BIN codegraph binary (default: command -v codegraph)
  18. # AGENT_EVAL_OUT output dir (default: /tmp/agent-eval)
  19. # MODEL / EFFORT claude model/effort (default: sonnet / high — the
  20. # standing A/B policy; see CLAUDE.md, don't raise)
  21. set -uo pipefail
  22. REPO="${1:?usage: run-all.sh <repo-path> \"<question>\" [headless|tmux|all]}"
  23. Q="${2:?question required}"
  24. MODE="${3:-headless}"
  25. # Split "Q1||Q2||Q3" into turns (kept bash-3.2-safe: macOS ships 3.2).
  26. TURNS=()
  27. rest="$Q"
  28. while [ "$rest" != "${rest#*||}" ]; do
  29. TURNS+=("${rest%%||*}")
  30. rest="${rest#*||}"
  31. done
  32. TURNS+=("$rest")
  33. CG_BIN="${CG_BIN:-$(command -v codegraph)}"
  34. OUT="${AGENT_EVAL_OUT:-/tmp/agent-eval}"
  35. HARNESS="$(cd "$(dirname "$0")" && pwd)"
  36. mkdir -p "$OUT"
  37. # Neutralize any ambient CodeGraph prompt-hook (~/.claude) in BOTH arms:
  38. # the hook injects codegraph context into every prompt, which contaminates
  39. # the without-arm (free structural context) and double-counts the with-arm.
  40. # The A/B's only variable must be the MCP server wired below.
  41. export CODEGRAPH_NO_PROMPT_HOOK=1
  42. # Hide the codegraph CLI from BOTH arms, so the only way to reach codegraph is
  43. # the MCP server wired below — which is what makes it the A/B's single variable.
  44. #
  45. # Both arms have Bash, and the target repo carries the .codegraph/ index the
  46. # with-arm needs. Agents FIND that: 14 of 15 without-arm runs in one 7-repo pass
  47. # ran `codegraph explore` through Bash (one via `ls .codegraph && codegraph
  48. # explore …`), so that arm was measuring codegraph-over-CLI, not
  49. # codegraph-absent. It matters in the with-arm too — output that arrives through
  50. # Bash is attributed to Bash, understating what codegraph itself occupies.
  51. #
  52. # The binary usually shares a directory with tools the run needs (claude itself
  53. # lives next to it here), so dropping the whole directory is not an option.
  54. # Substitute an equivalent directory IN PLACE: symlinks to every entry except
  55. # codegraph, keeping PATH order and precedence intact.
  56. SHIM_BIN="$OUT/nocg-bin"
  57. rm -rf "$SHIM_BIN"; mkdir -p "$SHIM_BIN"
  58. sanitized_path() {
  59. local out="" d e
  60. local IFS=:
  61. for d in $PATH; do
  62. [ -n "$d" ] || continue
  63. if [ -x "$d/codegraph" ]; then
  64. for e in "$d"/*; do
  65. [ "$(basename "$e")" = codegraph ] && continue
  66. ln -sf "$e" "$SHIM_BIN/" 2>/dev/null
  67. done
  68. d="$SHIM_BIN"
  69. fi
  70. out="${out:+$out:}$d"
  71. done
  72. printf '%s' "$out"
  73. }
  74. ARM_PATH="$(sanitized_path)"
  75. if PATH="$ARM_PATH" command -v codegraph >/dev/null 2>&1; then
  76. echo "WARNING: 'codegraph' is still on the arm PATH — runs will be contaminated"
  77. fi
  78. for t in claude node; do
  79. PATH="$ARM_PATH" command -v "$t" >/dev/null || { echo "sanitized PATH lost '$t' — refusing to run"; exit 1; }
  80. done
  81. # Hiding it from PATH is not enough. An agent denied `codegraph` ran
  82. # `find / -maxdepth 4 -iname "*codegraph*"`, found the binary, and invoked it by
  83. # ABSOLUTE PATH — so block the invocation itself with a PreToolUse hook. Written
  84. # into $OUT as a run artifact rather than a repo file, same as the MCP configs.
  85. # The pattern deliberately matches only COMMAND positions: `grep codegraph x`,
  86. # `ls .codegraph` and `which codegraph` are looking, not using, and pass through.
  87. CG_CMD_RE='(^|[;&|(]|&&|\|\||\$\(|`)[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*[A-Za-z0-9_./~-]*codegraph([[:space:]]|$)'
  88. cat > "$OUT/no-cli-hook.sh" <<HOOK
  89. #!/usr/bin/env bash
  90. # Deny Bash invocations of the codegraph CLI so the MCP server stays the A/B's
  91. # single variable. Looking for it is fine; running it is not.
  92. set -uo pipefail
  93. cmd="\$(cat | jq -r '.tool_input.command // empty' 2>/dev/null)"
  94. if printf '%s' "\$cmd" | grep -Eq '$CG_CMD_RE'; then
  95. msg="The codegraph CLI is not available in this session. Answer using the tools you have."
  96. jq -n --arg m "\$msg" '{reason:\$m, hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:\$m}}'
  97. fi
  98. exit 0
  99. HOOK
  100. chmod +x "$OUT/no-cli-hook.sh"
  101. cat > "$OUT/hook-settings.json" <<JSON
  102. {"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"bash $OUT/no-cli-hook.sh"}]}]}}
  103. JSON
  104. command -v jq >/dev/null || { echo "jq is required for the CLI-block hook — install it or the arms will be contaminated"; exit 1; }
  105. # Prove the hook denies a real invocation and lets a mere mention through.
  106. _probe() { printf '{"tool_input":{"command":%s}}' "$1" | bash "$OUT/no-cli-hook.sh" | grep -c deny; }
  107. [ "$(_probe '"/Users/x/.local/bin/codegraph explore \"q\""')" = 1 ] || { echo "hook fails to block an absolute-path invocation"; exit 1; }
  108. [ "$(_probe '"grep -rn codegraph src/"')" = 0 ] || { echo "hook over-blocks a plain mention"; exit 1; }
  109. [ -n "$CG_BIN" ] || { echo "no codegraph binary on PATH (set CG_BIN)"; exit 1; }
  110. [ -d "$REPO/.codegraph" ] || { echo "no .codegraph index at $REPO — index it first"; exit 1; }
  111. case "$MODE" in headless|tmux|all) ;; *) echo "mode must be headless|tmux|all (got '$MODE')"; exit 1;; esac
  112. # MCP config files (path form avoids inline-JSON quoting through tmux).
  113. cat > "$OUT/mcp-codegraph.json" <<JSON
  114. {"mcpServers":{"codegraph":{"command":"$CG_BIN","args":["serve","--mcp","--path","$REPO"]}}}
  115. JSON
  116. echo '{"mcpServers":{}}' > "$OUT/mcp-empty.json"
  117. echo "###### codegraph: $CG_BIN"
  118. echo "###### repo: $REPO"
  119. echo "###### turns: ${#TURNS[@]}"
  120. for t in "${TURNS[@]}"; do echo "###### - $t"; done
  121. echo
  122. # Pull the session id out of a segment's result event so the next turn can
  123. # --resume it (rather than minting a --session-id, which needs a valid uuid).
  124. session_id_of() {
  125. node -e '
  126. const fs=require("fs");
  127. for (const l of fs.readFileSync(process.argv[1],"utf8").split("\n").reverse()) {
  128. if (!l) continue; let e; try { e=JSON.parse(l) } catch { continue }
  129. if (e.session_id) { console.log(e.session_id); break }
  130. }' "$1" 2>/dev/null
  131. }
  132. # Headless arm: claude -p with stream-json -> exact tool sequence + tokens/cost
  133. # + residual context occupancy. One session, one segment file per turn.
  134. headless() {
  135. local label="$1" cfg="$2"
  136. echo "############################## HEADLESS [$label] ##############################"
  137. local sid="" seg=0 out="" files=()
  138. : > "$OUT/run-$label.err"
  139. for q in "${TURNS[@]}"; do
  140. seg=$((seg + 1))
  141. out="$OUT/run-$label.jsonl"
  142. [ "$seg" -gt 1 ] && out="$OUT/run-$label.t$seg.jsonl"
  143. local resume=()
  144. [ -n "$sid" ] && resume=(--resume "$sid")
  145. ( cd "$REPO" && PATH="$ARM_PATH" claude -p "$q" \
  146. --output-format stream-json --verbose \
  147. --permission-mode bypassPermissions \
  148. --model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" \
  149. --max-budget-usd 4 \
  150. --strict-mcp-config --mcp-config "$cfg" \
  151. --settings "$OUT/hook-settings.json" \
  152. ${resume[@]+"${resume[@]}"} \
  153. </dev/null > "$out" 2>>"$OUT/run-$label.err" )
  154. echo "exit $? -> $out ($(wc -l < "$out" | tr -d ' ') lines) [turn $seg/${#TURNS[@]}]"
  155. files+=("$out")
  156. sid="$(session_id_of "$out")"
  157. if [ -z "$sid" ] && [ "$seg" -lt "${#TURNS[@]}" ]; then
  158. echo " WARN: no session_id in $out — later turns would start a FRESH context; stopping this arm"
  159. break
  160. fi
  161. done
  162. tail -2 "$OUT/run-$label.err" 2>/dev/null
  163. node "$HARNESS/parse-run.mjs" "${files[@]}" 2>&1 || true
  164. echo
  165. }
  166. # CG_ARMS=with|without|both — re-run one arm without redoing the other.
  167. ARMS="${CG_ARMS:-both}"
  168. if [ "$MODE" = headless ] || [ "$MODE" = all ]; then
  169. case "$ARMS" in both|with) headless "headless-with" "$OUT/mcp-codegraph.json";; esac
  170. case "$ARMS" in both|without) headless "headless-without" "$OUT/mcp-empty.json";; esac
  171. fi
  172. if [ "$MODE" = tmux ] || [ "$MODE" = all ]; then
  173. echo "############################## INTERACTIVE [with] ##############################"
  174. CLAUDE_EXTRA_ARGS="--model ${MODEL:-sonnet} --effort ${EFFORT:-high} --strict-mcp-config --mcp-config $OUT/mcp-codegraph.json" \
  175. bash "$HARNESS/itrun.sh" "$REPO" "int-with" "${TURNS[0]}" 2>&1 || echo "[itrun WITH failed]"
  176. echo
  177. echo "############################## INTERACTIVE [without] ##############################"
  178. CLAUDE_EXTRA_ARGS="--model ${MODEL:-sonnet} --effort ${EFFORT:-high} --strict-mcp-config --mcp-config $OUT/mcp-empty.json" \
  179. bash "$HARNESS/itrun.sh" "$REPO" "int-without" "${TURNS[0]}" 2>&1 || echo "[itrun WITHOUT failed]"
  180. echo
  181. fi
  182. echo "############################## RUN-ALL COMPLETE ##############################"