chore: import upstream snapshot with attribution
Deploy site to GitHub Pages / build (push) Failing after 1s
Deploy site to GitHub Pages / deploy (push) Has been skipped

This commit is contained in:
wehub-resource-sync
2026-07-13 12:02:56 +08:00
commit 5f98960d22
495 changed files with 149129 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Does the agent PICK codegraph_node to read a file, vs the built-in Read tool?
# Build A/B: NEW build (HEAD, codegraph_node has Read parity) vs BASELINE build
# (a ref where it doesn't), BOTH codegraph-attached + pre-warmed, same task. The
# metric is tool CHOICE: Read calls vs codegraph_node[file] calls per run.
#
# Usage: ab-adoption.sh <indexed-repo> "<task>" [runs-per-arm] [baseline-ref]
# Env: AGENT_EVAL_OUT (default: /tmp/ab-adoption)
set -uo pipefail
TARGET="${1:?usage: ab-adoption.sh <indexed-repo> \"<task>\" [runs] [baseline-ref]}"
TASK="${2:?task required}"
RUNS="${3:-2}"
BASE_REF="${4:-HEAD~1}"
ENGINE="$(cd "$(dirname "$0")/../.." && pwd)"
BIN="$ENGINE/dist/bin/codegraph.js"
OUT="${AGENT_EVAL_OUT:-/tmp/ab-adoption}"
command -v claude >/dev/null || { echo "claude CLI not on PATH"; exit 1; }
[ -d "$TARGET/.codegraph" ] || { echo "target not indexed: run 'codegraph init $TARGET' first"; exit 1; }
git -C "$ENGINE" diff --quiet && git -C "$ENGINE" diff --cached --quiet || { echo "engine has uncommitted changes — commit/stash first"; exit 1; }
CHANGED=$(git -C "$ENGINE" diff --name-only "$BASE_REF" HEAD -- src 2>/dev/null)
[ -n "$CHANGED" ] || { echo "no src/ changes between $BASE_REF and HEAD"; exit 1; }
cleanup() {
pkill -9 -f "serve --mcp --path $OUT/" 2>/dev/null
git -C "$ENGINE" checkout HEAD -- $CHANGED 2>/dev/null
( cd "$ENGINE" && npm run build >/dev/null 2>&1 )
}
trap cleanup EXIT
mkdir -p "$OUT"
echo "###### target=$TARGET runs/arm=$RUNS baseline=$BASE_REF"
echo "###### changed: $(echo "$CHANGED" | tr '\n' ' ')"
echo "###### task=$TASK"; echo
prewarm() {
pkill -9 -f "serve --mcp --path $1" 2>/dev/null
CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS=1800000 node "$BIN" serve --mcp --path "$1" </dev/null >/dev/null 2>&1 &
node -e 'const fs=require("fs");let n=0;const t=setInterval(()=>{if(fs.existsSync(process.argv[1]+"/.codegraph/daemon.sock")){clearInterval(t);process.exit(0)}if(n++>150){clearInterval(t);process.exit(1)}},100)' "$1" >/dev/null 2>&1
}
# Per-run tool-choice counts: Read vs codegraph_node[file] vs [symbol].
count() {
node -e '
const fs=require("fs");
const lines=fs.readFileSync(process.argv[1],"utf8").split("\n").filter(Boolean);
let read=0,cgFile=0,cgSym=0,cgOther=0,exposed="?";
for(const l of lines){try{const o=JSON.parse(l);
if(o.type==="system"&&o.subtype==="init"){exposed=(o.tools||[]).filter(t=>/codegraph/.test(t)).length;}
const blocks=o.message?.content||[];
for(const b of (Array.isArray(blocks)?blocks:[])){
if(b.type!=="tool_use")continue;
if(b.name==="Read")read++;
else if(b.name==="mcp__codegraph__codegraph_node"){ if(b.input&&b.input.symbol)cgSym++; else cgFile++; }
else if(/mcp__codegraph__/.test(b.name))cgOther++;
}
}catch{}}
console.log(` Read=${read} codegraph_node[file]=${cgFile} codegraph_node[symbol]=${cgSym} other_cg=${cgOther} (cg exposed=${exposed})`);
' "$1"
}
run_arm() { # label, N
local label="$1" n="$2"
local c="$OUT/mcp-$label.json"
for i in $(seq 1 "$n"); do
local tgt="$OUT/t-$label-$i"
rm -rf "$tgt"
rsync -a --exclude node_modules --exclude .git --exclude dist --exclude .codegraph "$TARGET/" "$tgt/"
node "$BIN" init "$tgt" >/dev/null 2>&1
printf '{"mcpServers":{"codegraph":{"command":"env","args":["CODEGRAPH_WASM_RELAUNCHED=1","node","%s","serve","--mcp","--path","%s"]}}}' "$BIN" "$tgt" > "$c"
prewarm "$tgt"
echo "----- [$label] run $i -----"
( cd "$tgt" && claude -p "$TASK" \
--output-format stream-json --verbose --permission-mode bypassPermissions \
--model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 --strict-mcp-config --mcp-config "$c" \
</dev/null > "$OUT/run-$label-$i.jsonl" 2>"$OUT/run-$label-$i.err" )
count "$OUT/run-$label-$i.jsonl"
pkill -9 -f "serve --mcp --path $tgt" 2>/dev/null
done
echo
}
echo "== NEW build (HEAD: codegraph_node has Read parity) =="
( cd "$ENGINE" && npm run build >/dev/null 2>&1 ) && echo "built"
run_arm new "$RUNS"
echo "== BASELINE build ($BASE_REF) =="
git -C "$ENGINE" checkout "$BASE_REF" -- $CHANGED
( cd "$ENGINE" && npm run build >/dev/null 2>&1 ) && echo "built"
run_arm baseline "$RUNS"
echo "###### DONE — compare [new] vs [baseline]: does codegraph_node[file] rise / Read fall? Logs: $OUT"
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# A/B the PreToolUse(Read) REDIRECT hook (P1): does steering Read → codegraph_node
# file-view actually move the agent off Read during implementation? BOTH arms use
# the CURRENT build with codegraph attached and pre-warmed; the only difference is
# the hook. Isolates the hook's behavioral effect from the build/file-view change
# (use ab-new-vs-baseline.sh for the build A/B).
#
# arm [nohook] — codegraph on, no hook (does the better file-view get picked on its own?)
# arm [hook] — codegraph on, + redirect hook (does routing close it?)
#
# Reliable attach (works nested): each arm pre-warms a persistent daemon and skips
# the startup re-exec (CODEGRAPH_WASM_RELAUNCHED=1), so claude connects before the
# agent's first turn. Judge by ACTUAL codegraph usage in parse-run.mjs's "by type",
# not claude's init snapshot (which can read pending even when it then connects).
#
# Usage: ab-hook.sh <indexed-repo> "<implementation task>" [runs-per-arm]
# <indexed-repo> a repo with a .codegraph index (copied per arm; never mutated)
# "<task>" a GENUINELY-NEW implementation task (verify it isn't already done)
# [runs-per-arm] default 2 (n=1 is noisy — the doctrine says >=2)
# Env: AGENT_EVAL_OUT (default: /tmp/ab-hook)
set -uo pipefail
TARGET="${1:?usage: ab-hook.sh <indexed-repo> \"<task>\" [runs-per-arm]}"
TASK="${2:?task required}"
RUNS="${3:-2}"
ENGINE="$(cd "$(dirname "$0")/../.." && pwd)"
BIN="$ENGINE/dist/bin/codegraph.js"
HOOK="$ENGINE/scripts/agent-eval/redirect-read-hook.sh"
OUT="${AGENT_EVAL_OUT:-/tmp/ab-hook}"
PARSE="$ENGINE/scripts/agent-eval/parse-run.mjs"
command -v claude >/dev/null || { echo "claude CLI not on PATH"; exit 1; }
command -v jq >/dev/null || { echo "jq not on PATH (the hook needs it)"; exit 1; }
[ -d "$TARGET/.codegraph" ] || { echo "target not indexed: run 'codegraph init $TARGET' first"; exit 1; }
chmod +x "$HOOK"
cleanup() { pkill -9 -f "serve --mcp --path $OUT/" 2>/dev/null; }
trap cleanup EXIT
mkdir -p "$OUT"
echo "###### engine=$ENGINE"
echo "###### target=$TARGET runs/arm=$RUNS"
echo "###### task=$TASK"
echo
( cd "$ENGINE" && npm run build >/dev/null 2>&1 ) && echo "built"
# A settings file carrying ONLY the PreToolUse(Read) redirect hook.
HOOK_SETTINGS="$OUT/hook-settings.json"
jq -n --arg cmd "bash $HOOK" \
'{hooks:{PreToolUse:[{matcher:"Read",hooks:[{type:"command",command:$cmd}]}]}}' > "$HOOK_SETTINGS"
prewarm() { # target — spawn a persistent daemon and wait for its socket
pkill -9 -f "serve --mcp --path $1" 2>/dev/null
CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS=1800000 node "$BIN" serve --mcp --path "$1" </dev/null >/dev/null 2>&1 &
node -e 'const fs=require("fs");let n=0;const t=setInterval(()=>{if(fs.existsSync(process.argv[1]+"/.codegraph/daemon.sock")){clearInterval(t);process.exit(0)}if(n++>150){clearInterval(t);process.exit(1)}},100)' "$1" \
&& echo " daemon warm: $1" || echo " WARN: daemon never bound for $1"
}
run_one() { # arm-label, run-index, use-hook(0|1)
local label="$1" idx="$2" hook="$3"
local tgt="$OUT/t-$label-$idx" c="$OUT/mcp-$label.json"
rm -rf "$tgt"
rsync -a --exclude node_modules --exclude .git --exclude dist --exclude .codegraph "$TARGET/" "$tgt/"
node "$BIN" init "$tgt" >/dev/null 2>&1
printf '{"mcpServers":{"codegraph":{"command":"env","args":["CODEGRAPH_WASM_RELAUNCHED=1","node","%s","serve","--mcp","--path","%s"]}}}' "$BIN" "$tgt" > "$c"
prewarm "$tgt"
local extra=()
[ "$hook" = "1" ] && extra=(--settings "$HOOK_SETTINGS")
echo "----- [$label] run $idx -----"
# ${extra[@]+...} guard: bash 3.2 (macOS) under `set -u` errors on an empty
# array expansion otherwise, which would skip the no-hook arm's claude run.
( cd "$tgt" && claude -p "$TASK" \
--output-format stream-json --verbose --permission-mode bypassPermissions \
--model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 --strict-mcp-config --mcp-config "$c" ${extra[@]+"${extra[@]}"} \
</dev/null > "$OUT/run-$label-$idx.jsonl" 2>"$OUT/run-$label-$idx.err" )
node "$PARSE" "$OUT/run-$label-$idx.jsonl" 2>&1 | grep -E "by type|Result" || echo " (parse failed — see $OUT/run-$label-$idx.jsonl)"
pkill -9 -f "serve --mcp --path $tgt" 2>/dev/null
echo
}
for i in $(seq 1 "$RUNS"); do run_one nohook "$i" 0; done
for i in $(seq 1 "$RUNS"); do run_one hook "$i" 1; done
echo "###### DONE. Compare [nohook] vs [hook] 'by type' — Read should fall and"
echo "###### mcp__codegraph__codegraph_node should rise in the [hook] arm. Logs: $OUT"
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Sufficiency A/B for an IMPLEMENTATION task (the agent edits): when it uses
# codegraph (explore/node) to understand before editing, does it still Read? Like
# ab-sufficiency.sh but copies+indexes a FRESH target per run (the agent mutates
# it), so runs don't see each other's edits.
#
# WITH codegraph (pre-warmed) vs WITHOUT (empty MCP), N runs each. Reports
# explore/node vs Read/Grep + the files Read, and whether the build still passes.
#
# Usage: ab-impl.sh <indexed-repo> "<task>" [runs] [build-cmd]
# Env: AGENT_EVAL_OUT (default: /tmp/ab-impl)
set -uo pipefail
REPO="${1:?usage: ab-impl.sh <indexed-repo> \"<task>\" [runs] [build-cmd]}"
Q="${2:?task required}"
RUNS="${3:-2}"
BUILD_CMD="${4:-}"
ENGINE="$(cd "$(dirname "$0")/../.." && pwd)"
BIN="$ENGINE/dist/bin/codegraph.js"
OUT="${AGENT_EVAL_OUT:-/tmp/ab-impl}"
command -v claude >/dev/null || { echo "claude CLI not on PATH"; exit 1; }
[ -d "$REPO/.codegraph" ] || { echo "no .codegraph index at $REPO"; exit 1; }
cleanup(){ pkill -9 -f "serve --mcp --path $OUT/" 2>/dev/null; }
trap cleanup EXIT
mkdir -p "$OUT"
( cd "$ENGINE" && npm run build >/dev/null 2>&1 ) && echo "built engine"
echo "###### repo=$REPO runs/arm=$RUNS"
echo "###### task=$Q"; echo
echo '{"mcpServers":{}}' > "$OUT/mcp-empty.json"
prewarm(){
pkill -9 -f "serve --mcp --path $1" 2>/dev/null
CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS=1800000 node "$BIN" serve --mcp --path "$1" </dev/null >/dev/null 2>&1 &
node -e 'const fs=require("fs");let n=0;const t=setInterval(()=>{if(fs.existsSync(process.argv[1]+"/.codegraph/daemon.sock")){clearInterval(t);process.exit(0)}if(n++>150){clearInterval(t);process.exit(1)}},100)' "$1" >/dev/null 2>&1
}
analyze(){
node -e '
const fs=require("fs");
const L=fs.readFileSync(process.argv[1],"utf8").split("\n").filter(Boolean);
let ex=0,nf=0,ns=0,oc=0,gr=0,ed=0,exposed="?";const reads=[];
for(const l of L){try{const o=JSON.parse(l);
if(o.type==="system"&&o.subtype==="init")exposed=(o.tools||[]).filter(t=>/codegraph/.test(t)).length;
for(const b of (o.message?.content||[])){if(b.type!=="tool_use")continue;
if(b.name==="mcp__codegraph__codegraph_explore")ex++;
else if(b.name==="mcp__codegraph__codegraph_node"){if(b.input&&b.input.symbol)ns++;else nf++;}
else if(/mcp__codegraph__/.test(b.name))oc++;
else if(b.name==="Read")reads.push((b.input?.file_path||"").split("/").pop());
else if(b.name==="Grep")gr++;
else if(b.name==="Edit"||b.name==="Write")ed++;
}}catch{}}
console.log(` explore=${ex} node[sym]=${ns} node[file]=${nf} other_cg=${oc} | Read=${reads.length}${reads.length?" ("+reads.join(", ")+")":""} Grep=${gr} Edit=${ed} [cg exposed=${exposed}]`);
' "$1"
}
run(){ # label, withCodegraph(0/1)
local label="$1" wcg="$2"
for i in $(seq 1 "$RUNS"); do
local tgt="$OUT/t-$label-$i" cfg="$OUT/mcp-$label.json"
rm -rf "$tgt"
rsync -a --exclude node_modules --exclude .git --exclude dist --exclude .codegraph "$REPO/" "$tgt/"
node "$BIN" init "$tgt" >/dev/null 2>&1
if [ "$wcg" = "1" ]; then
printf '{"mcpServers":{"codegraph":{"command":"env","args":["CODEGRAPH_WASM_RELAUNCHED=1","node","%s","serve","--mcp","--path","%s"]}}}' "$BIN" "$tgt" > "$cfg"
prewarm "$tgt"
else cp "$OUT/mcp-empty.json" "$cfg"; fi
( cd "$tgt" && claude -p "$Q" --output-format stream-json --verbose \
--permission-mode bypassPermissions --model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 \
--strict-mcp-config --mcp-config "$cfg" </dev/null > "$OUT/$label-$i.jsonl" 2>"$OUT/$label-$i.err" )
echo "[$label] run $i:"; analyze "$OUT/$label-$i.jsonl"
if [ -n "$BUILD_CMD" ]; then ( cd "$tgt" && eval "$BUILD_CMD" >/dev/null 2>&1 && echo " build: PASS" || echo " build: FAIL" ); fi
pkill -9 -f "serve --mcp --path $tgt" 2>/dev/null
done
echo
}
echo "== WITH codegraph =="; run with 1
echo "== WITHOUT (Read/Grep only) =="; run without 0
echo "###### DONE: $OUT"
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# A/B a codegraph retrieval/steering change: the NEW build (current HEAD) vs a
# BASELINE build (a git ref) — BOTH with codegraph attached — on the same
# implementation task, measuring how many Read vs codegraph calls the agent
# makes. ISOLATES the change (unlike run-all.sh's with-vs-without). The agent
# works on a throwaway copy of the target, so your repos are never touched.
#
# Reliable attach (works even when this is itself run nested inside a Claude
# session): each arm PRE-WARMS a persistent codegraph daemon for its target so
# claude connects to an already-bound, index-loaded daemon instantly — before
# the agent's first turn — and SKIPS codegraph's startup re-exec via
# CODEGRAPH_WASM_RELAUNCHED=1. Without this, on a multi-step task the agent
# dives into Read/grep before codegraph finishes its ~2-3s startup (worse under
# the CPU contention of a nested run) and runs with NO codegraph.
#
# Gotcha: claude's `system/init` snapshot can read status:"pending" / 0 tools
# even when the server then connects fine — judge by ACTUAL codegraph usage in
# parse-run.mjs's "by type", not the init line.
#
# Usage: ab-new-vs-baseline.sh <indexed-repo> "<task>" [baseline-ref]
# <indexed-repo> a repo with a .codegraph index (copied per arm)
# "<task>" an implementation task, e.g. "Add X to Y and wire it through"
# [baseline-ref] git ref for the BEFORE build (default: HEAD~1)
# Env: AGENT_EVAL_OUT (default: /tmp/ab-new-vs-baseline)
set -uo pipefail
TARGET="${1:?usage: ab-new-vs-baseline.sh <indexed-repo> \"<task>\" [baseline-ref]}"
TASK="${2:?task required}"
BASE_REF="${3:-HEAD~1}"
ENGINE="$(cd "$(dirname "$0")/../.." && pwd)"
BIN="$ENGINE/dist/bin/codegraph.js"
OUT="${AGENT_EVAL_OUT:-/tmp/ab-new-vs-baseline}"
PARSE="$ENGINE/scripts/agent-eval/parse-run.mjs"
command -v claude >/dev/null || { echo "claude CLI not on PATH"; exit 1; }
[ -d "$TARGET/.codegraph" ] || { echo "target not indexed: run 'codegraph init $TARGET' first"; exit 1; }
if ! git -C "$ENGINE" diff --quiet || ! git -C "$ENGINE" diff --cached --quiet; then
echo "engine repo has uncommitted changes — commit or stash first (this script checks files out)"; exit 1
fi
CHANGED=$(git -C "$ENGINE" diff --name-only "$BASE_REF" HEAD -- src 2>/dev/null)
[ -n "$CHANGED" ] || { echo "no src/ changes between $BASE_REF and HEAD — nothing to A/B"; exit 1; }
# On exit: kill any eval daemons + restore the engine to HEAD.
cleanup() {
pkill -9 -f "serve --mcp --path $OUT/" 2>/dev/null
git -C "$ENGINE" checkout HEAD -- $CHANGED 2>/dev/null
( cd "$ENGINE" && npm run build >/dev/null 2>&1 )
}
trap cleanup EXIT
mkdir -p "$OUT"
echo "###### engine=$ENGINE baseline=$BASE_REF"
echo "###### changed: $(echo "$CHANGED" | tr '\n' ' ')"
echo "###### target=$TARGET"
echo "###### task=$TASK"
echo
# Two pristine copies so each arm starts clean (the agent edits its own copy).
rm -rf "$OUT/t-new" "$OUT/t-base"
rsync -a --exclude node_modules --exclude .git --exclude dist --exclude .codegraph "$TARGET/" "$OUT/t-new/"
cp -R "$OUT/t-new" "$OUT/t-base"
prewarm() { # target — spawn a persistent daemon (current $BIN) and wait for its socket
pkill -9 -f "serve --mcp --path $1" 2>/dev/null
CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS=1800000 node "$BIN" serve --mcp --path "$1" </dev/null >/dev/null 2>&1 &
node -e 'const fs=require("fs");let n=0;const t=setInterval(()=>{if(fs.existsSync(process.argv[1]+"/.codegraph/daemon.sock")){clearInterval(t);process.exit(0)}if(n++>150){clearInterval(t);process.exit(1)}},100)' "$1" \
&& echo " daemon warm: $1" || echo " WARN: daemon never bound for $1 (arm may run without codegraph)"
}
run_arm() { # label, target-copy
local label="$1" tgt="$2" c="$OUT/mcp-$1.json"
# Connect to the pre-warmed daemon; skip the startup re-exec for a fast attach.
printf '{"mcpServers":{"codegraph":{"command":"env","args":["CODEGRAPH_WASM_RELAUNCHED=1","node","%s","serve","--mcp","--path","%s"]}}}' "$BIN" "$tgt" > "$c"
prewarm "$tgt"
echo "############## ARM [$label] ##############"
( cd "$tgt" && claude -p "$TASK" \
--output-format stream-json --verbose --permission-mode bypassPermissions \
--model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 --strict-mcp-config --mcp-config "$c" \
</dev/null > "$OUT/run-$label.jsonl" 2>"$OUT/run-$label.err" )
node "$PARSE" "$OUT/run-$label.jsonl" 2>&1 | grep -E "by type|Result" || echo " (parse failed — see $OUT/run-$label.jsonl)"
pkill -9 -f "serve --mcp --path $tgt" 2>/dev/null
echo
}
echo "== NEW build (HEAD) =="
( cd "$ENGINE" && npm run build >/dev/null 2>&1 ) && echo " built"
node "$BIN" init "$OUT/t-new" >/dev/null 2>&1 && echo " indexed t-new"
run_arm new "$OUT/t-new"
echo "== BASELINE build ($BASE_REF) =="
# Per-file: a file ADDED since baseline has no pathspec on the ref — and a
# single multi-file checkout with one bad pathspec checks out NOTHING, which
# silently ran the NEW build in the baseline arm. Absent-on-baseline → remove.
for f in $CHANGED; do
git -C "$ENGINE" checkout "$BASE_REF" -- "$f" 2>/dev/null || rm -f "$ENGINE/$f"
done
( cd "$ENGINE" && npm run build >/dev/null 2>&1 ) && echo " built"
node "$BIN" init "$OUT/t-base" >/dev/null 2>&1 && echo " indexed t-base"
run_arm baseline "$OUT/t-base"
echo "###### DONE. Compare the [new] vs [baseline] 'by type' counts above"
echo "###### (especially Read vs mcp__codegraph__*). Full logs in: $OUT"
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Sufficiency A/B: on a real understanding/flow question, WHEN the agent uses
# codegraph (explore/node), does it still Read? Premise under test: explore/node
# return source WITH line numbers, so a Read should not be needed.
#
# WITH codegraph (pre-warmed daemon, reliable nested attach) vs WITHOUT (empty
# MCP, Read/Grep only), N runs each, on a throwaway copy of the repo. Reports
# explore/node vs Read/Grep, and LISTS the files Read in the WITH arm so a true
# sufficiency gap (an indexed source file) is distinguishable from out-of-scope
# (configs, docs, a file codegraph didn't index).
#
# Usage: ab-sufficiency.sh <indexed-repo> "<question>" [runs-per-arm]
# Env: AGENT_EVAL_OUT (default: /tmp/ab-sufficiency)
set -uo pipefail
REPO="${1:?usage: ab-sufficiency.sh <indexed-repo> \"<question>\" [runs]}"
Q="${2:?question required}"
RUNS="${3:-2}"
ENGINE="$(cd "$(dirname "$0")/../.." && pwd)"
BIN="$ENGINE/dist/bin/codegraph.js"
OUT="${AGENT_EVAL_OUT:-/tmp/ab-sufficiency}"
TGT="$OUT/target"
command -v claude >/dev/null || { echo "claude CLI not on PATH"; exit 1; }
[ -d "$REPO/.codegraph" ] || { echo "no .codegraph index at $REPO"; exit 1; }
cleanup(){ pkill -9 -f "serve --mcp --path $TGT" 2>/dev/null; }
trap cleanup EXIT
mkdir -p "$OUT"
( cd "$ENGINE" && npm run build >/dev/null 2>&1 ) && echo "built"
# Throwaway copy + fresh index (the agent works here; a read-only question won't
# edit, but isolate anyway). Excludes the source repo's index/build/vcs.
rm -rf "$TGT"
rsync -a --exclude node_modules --exclude .git --exclude dist --exclude .codegraph "$REPO/" "$TGT/"
node "$BIN" init "$TGT" >/dev/null 2>&1 && echo "indexed copy ($(node "$BIN" status --json 2>/dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).fileCount+" files")}catch{console.log("?")}})' 2>/dev/null || echo '?'))"
echo "###### repo=$REPO runs/arm=$RUNS"
echo "###### Q=$Q"; echo
echo '{"mcpServers":{}}' > "$OUT/mcp-empty.json"
printf '{"mcpServers":{"codegraph":{"command":"env","args":["CODEGRAPH_WASM_RELAUNCHED=1","node","%s","serve","--mcp","--path","%s"]}}}' "$BIN" "$TGT" > "$OUT/mcp-cg.json"
prewarm(){
pkill -9 -f "serve --mcp --path $TGT" 2>/dev/null
CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS=1800000 node "$BIN" serve --mcp --path "$TGT" </dev/null >/dev/null 2>&1 &
node -e 'const fs=require("fs");let n=0;const t=setInterval(()=>{if(fs.existsSync(process.argv[1]+"/.codegraph/daemon.sock")){clearInterval(t);process.exit(0)}if(n++>150){clearInterval(t);process.exit(1)}},100)' "$TGT" >/dev/null 2>&1
}
analyze(){
node -e '
const fs=require("fs");
const L=fs.readFileSync(process.argv[1],"utf8").split("\n").filter(Boolean);
let ex=0,nf=0,ns=0,oc=0,gr=0,exposed="?";const reads=[];
for(const l of L){try{const o=JSON.parse(l);
if(o.type==="system"&&o.subtype==="init")exposed=(o.tools||[]).filter(t=>/codegraph/.test(t)).length;
for(const b of (o.message?.content||[])){if(b.type!=="tool_use")continue;
if(b.name==="mcp__codegraph__codegraph_explore")ex++;
else if(b.name==="mcp__codegraph__codegraph_node"){if(b.input&&b.input.symbol)ns++;else nf++;}
else if(/mcp__codegraph__/.test(b.name))oc++;
else if(b.name==="Read")reads.push((b.input?.file_path||"").split("/").pop());
else if(b.name==="Grep")gr++;
}}catch{}}
console.log(` explore=${ex} node[sym]=${ns} node[file]=${nf} other_cg=${oc} | Read=${reads.length}${reads.length?" ("+reads.join(", ")+")":""} Grep=${gr} [cg exposed=${exposed}]`);
' "$1"
}
run(){ # label, cfg, prewarm(0/1)
local label="$1" cfg="$2" pw="$3"
for i in $(seq 1 "$RUNS"); do
[ "$pw" = "1" ] && prewarm
( cd "$TGT" && claude -p "$Q" --output-format stream-json --verbose \
--permission-mode bypassPermissions --model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 \
--strict-mcp-config --mcp-config "$cfg" </dev/null > "$OUT/$label-$i.jsonl" 2>"$OUT/$label-$i.err" )
echo "[$label] run $i:"; analyze "$OUT/$label-$i.jsonl"
done
echo
}
echo "== WITH codegraph (premise: explore/node used -> Read ~0) =="; run with "$OUT/mcp-cg.json" 1
echo "== WITHOUT (Read/Grep only — the contrast) =="; run without "$OUT/mcp-empty.json" 0
echo "###### DONE. In the WITH arm: are explore/node>0 and Read~0? Any Read of an INDEXED source file = sufficiency gap. Logs: $OUT"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Arm F (body-inlining trace + trace-first steering) across the same 6 repos as
# arms-matrix.sh, so F vs B isolates the trace-enrichment effect (same surface,
# old thin trace in B vs body-inlining trace here).
set -uo pipefail
H="$(cd "$(dirname "$0")" && pwd)"; RUNS="${RUNS:-2}"; C="${CORPUS:-/tmp/codegraph-corpus}"
ROWS=(
"$C/flutter-samples/add_to_app/books/flutter_module_books|How does the books UI build and what child widgets does it show?"
"$C/aspnet-realworld|How is creating an article handled? Trace the controller to the service."
"$C/spring-mall|How is a product-list request handled? Trace the controller to the service."
"$C/vapor-spi|How is a package-show request handled? Name the route and controller."
"$C/excalidraw|How does updating an element re-render the canvas on screen? Trace the flow."
"$C/spring-halo|How is publishing a post handled? Trace the controller to the service."
)
ARM="${ARM:-F}"
echo "### ARM $ARM START $(date) RUNS=$RUNS"
for row in "${ROWS[@]}"; do
repo="${row%%|*}"; q="${row#*|}"
for r in $(seq 1 "$RUNS"); do bash "$H/run-arms.sh" "$repo" "$q" "$ARM" "$r"; done
done
echo "### ARM $ARM COMPLETE $(date)"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Drive the tool-surface ablation across the chosen repos × arms (AE).
# Arms AD ask the canonical FLOW question; arm E asks a NON-flow survey
# question (the control probe — should degrade without explore+context).
# Output: /tmp/arms/<repo>/<arm>-r<n>.jsonl (parse with parse-arms.mjs).
set -uo pipefail
HARNESS="$(cd "$(dirname "$0")" && pwd)"
RUNS="${RUNS:-2}"
C="${CORPUS:-/tmp/codegraph-corpus}"
NFQ='What are the main modules/components of this codebase and what does each one do? Give an overview of how it is organized.'
# repo-path|flow-question (2 small, 2 medium, 2 large — spans the size range)
ROWS=(
"$C/flutter-samples/add_to_app/books/flutter_module_books|How does the books UI build and what child widgets does it show?"
"$C/aspnet-realworld|How is creating an article handled? Trace the controller to the service."
"$C/spring-mall|How is a product-list request handled? Trace the controller to the service."
"$C/vapor-spi|How is a package-show request handled? Name the route and controller."
"$C/excalidraw|How does updating an element re-render the canvas on screen? Trace the flow."
"$C/spring-halo|How is publishing a post handled? Trace the controller to the service."
)
echo "### ARMS MATRIX START $(date) RUNS=$RUNS"
for row in "${ROWS[@]}"; do
repo="${row%%|*}"; q="${row#*|}"
for arm in A B C D; do
for r in $(seq 1 "$RUNS"); do
bash "$HARNESS/run-arms.sh" "$repo" "$q" "$arm" "$r"
done
done
done
# E: non-flow control probe on two repos (must degrade without explore+context)
for repo in "$C/excalidraw" "$C/spring-mall"; do
for r in $(seq 1 "$RUNS"); do
bash "$HARNESS/run-arms.sh" "$repo" "$NFQ" E "$r"
done
done
echo "### ARMS MATRIX COMPLETE $(date)"
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# One-shot CodeGraph quality audit:
# set version -> ensure corpus repo -> wipe+reindex with that version ->
# run with/without A/B -> restore the local dev link.
#
# Usage: audit.sh <version> <repo-name> <repo-url> "<question>" [headless|all]
# <version> "local" (build + npm link this repo) | "latest" | a version (e.g. 0.7.10)
# <repo-name> dir name under the corpus dir
# <repo-url> git URL (cloned --depth 1 when the repo dir is missing)
# [mode] headless (default) | all (also the interactive tmux arms)
# Env: CORPUS corpus dir (default: /tmp/codegraph-corpus)
set -uo pipefail
VERSION="${1:?usage: audit.sh <version> <repo-name> <repo-url> \"<question>\" [mode]}"
NAME="${2:?repo-name required}"
URL="${3:?repo-url required}"
Q="${4:?question required}"
MODE="${5:-headless}"
HARNESS="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$HARNESS/../.." && pwd)" # codegraph repo root
CORPUS="${CORPUS:-/tmp/codegraph-corpus}"
REPO="$CORPUS/$NAME"
PKG="@colbymchenry/codegraph"
echo "==================== CodeGraph audit ===================="
echo "version=$VERSION repo=$NAME mode=$MODE corpus=$CORPUS"
echo
# 1. Set the codegraph version under test (mutates the global install).
if [ "$VERSION" = local ]; then
echo "→ [1/4] building + linking local dev build (local-install.sh)"
( cd "$REPO_ROOT" && ./scripts/local-install.sh ) || { echo "local-install.sh failed"; exit 1; }
else
echo "→ [1/4] installing $PKG@$VERSION globally"
npm install -g "$PKG@$VERSION" || { echo "npm install -g $PKG@$VERSION failed"; exit 1; }
fi
ACTUAL="$(codegraph --version 2>/dev/null || echo '?')"
echo " codegraph on PATH: $(command -v codegraph) -> $ACTUAL"
# 2. Ensure the corpus repo exists (clone shallow if missing, reuse if present).
mkdir -p "$CORPUS"
if [ -d "$REPO/.git" ]; then
echo "→ [2/4] reusing existing checkout: $REPO"
else
echo "→ [2/4] cloning $URL"
git clone --depth 1 "$URL" "$REPO" || { echo "git clone failed"; exit 1; }
fi
# 3. Wipe + re-index with THIS version (the index must be built by the same
# binary that serves it — different versions extract differently).
echo "→ [3/4] wiping .codegraph and re-indexing with $ACTUAL"
rm -rf "$REPO/.codegraph"
( cd "$REPO" && codegraph init -i ) || { echo "indexing failed"; exit 1; }
# 4. Run the with/without A/B.
echo "→ [4/4] running A/B harness (mode=$MODE)"
bash "$HARNESS/run-all.sh" "$REPO" "$Q" "$MODE"
# Restore the dev link (the normal working state in this repo).
echo
echo "→ restoring local dev link (local-install.sh)"
if ( cd "$REPO_ROOT" && ./scripts/local-install.sh >/dev/null 2>&1 ); then
echo " global codegraph restored to dev build"
else
echo " WARN: restore failed — run ./scripts/local-install.sh manually"
fi
echo "==================== audit complete ===================="
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Re-run the README "Benchmark Results" A/B (with vs without codegraph) on the
# current build: the 7 README repos, same queries, RUNS per arm (default 4).
# Output → /tmp/ab-readme/<repo>/run<n>/run-headless-{with,without}.jsonl
# Aggregate with parse-bench-readme.mjs. Repos must be cloned + indexed under
# $CORPUS (default /tmp/codegraph-corpus) by the build under test.
set -uo pipefail
H="$(cd "$(dirname "$0")" && pwd)"
C="${CORPUS:-/tmp/codegraph-corpus}"
RUNS="${RUNS:-4}"
ROWS=(
"vscode|How does the extension host communicate with the main process?"
"excalidraw|How does Excalidraw render and update canvas elements?"
"django|How does Django's ORM build and execute a query from a QuerySet?"
"tokio|How does tokio schedule and run async tasks on its runtime?"
"okhttp|How does OkHttp process a request through its interceptor chain?"
"gin|How does gin route requests through its middleware chain?"
"alamofire|How does Alamofire build, send, and validate a request?"
)
echo "### README A/B START $(date) RUNS=$RUNS"
for row in "${ROWS[@]}"; do
repo="${row%%|*}"; q="${row#*|}"
echo "===== $repo ====="
for run in $(seq 1 "$RUNS"); do
AGENT_EVAL_OUT="/tmp/ab-readme/$repo/run$run" bash "$H/run-all.sh" "$C/$repo" "$q" headless 2>&1 | grep -E "exit [0-9]" || echo " run$run: (no exit line)"
done
done
echo "### README A/B DONE $(date)"
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# One README repo, WITH-codegraph only, N runs. Each run appends a why-Read
# diagnostic so the agent explains any Read/Grep. (The WITHOUT baseline is
# codegraph-independent and already in the README — no point re-running it.)
# Output -> /tmp/ab-why/<repo>/with<n>.jsonl
# Usage: bench-why-repo.sh <repo-path> "<query>" [N]
set -uo pipefail
REPO="$1"; Q="$2"; N="${3:-4}"
NAME="$(basename "$REPO")"
CG="/Users/colby/Development/Personal/codegraph/dist/bin/codegraph.js"
OUT="/tmp/ab-why/$NAME"; mkdir -p "$OUT"
WHY=$'\n\nIMPORTANT — diagnostic: if you use the Read or Grep tool at ANY point, for EACH such call explain why codegraph_explore / codegraph_node did not already give you what you needed. End your entire answer with a section titled exactly "## Why I read" listing every Read and Grep you made and the precise reason codegraph fell short for it. If you used neither, write "## Why I read" then "none — codegraph was sufficient."'
printf '{"mcpServers":{"codegraph":{"command":"%s","args":["serve","--mcp","--path","%s"]}}}' "$CG" "$REPO" > "$OUT/cg.json"
for i in $(seq 1 "$N"); do
pkill -f "serve --mcp" 2>/dev/null; sleep 1; rm -f "$REPO/.codegraph/daemon.sock"
( cd "$REPO" && claude -p "$Q$WHY" --output-format stream-json --verbose \
--permission-mode bypassPermissions --model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 \
--strict-mcp-config --mcp-config "$OUT/cg.json" > "$OUT/with$i.jsonl" 2>"$OUT/with$i.err" )
echo "WITH run $i: exit $? ($(wc -l < "$OUT/with$i.jsonl" | tr -d ' ') lines)"
done
echo "DONE $NAME"
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# PreToolUse hook (experiment): deny Read of codegraph-indexed source files and
# steer the agent to codegraph_explore/codegraph_node instead. Tests whether
# codegraph can FULLY replace Read for code-understanding once the escape hatch
# is removed. Non-source reads (config, .env, markdown, new files) pass through.
#
# Wire via: claude ... --settings scripts/agent-eval/hook-settings.json
set -uo pipefail
input="$(cat)"
fp="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
case "$fp" in
*.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs|*.py|*.go|*.rs|*.java|*.rb|*.php|*.swift|*.kt|*.kts|*.c|*.cc|*.cpp|*.h|*.hpp|*.cs|*.lua|*.vue|*.svelte)
msg="Read is disabled for source files in this session — codegraph already has this file indexed (with line numbers, kept in sync on every change). Use codegraph_explore (several related symbols at once) or codegraph_node (one symbol's full source). If a symbol you need wasn't in a prior explore, run ANOTHER codegraph_explore with its exact name instead of reading the file."
jq -n --arg m "$msg" '{reason:$m, hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$m}}'
exit 0
;;
esac
exit 0
+15
View File
@@ -0,0 +1,15 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "bash /Users/colby/Development/Personal/codegraph/scripts/agent-eval/block-read-hook.sh"
}
]
}
]
}
}
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Drive an INTERACTIVE Claude Code session in tmux, send a prompt, wait for the
# agent to finish, then print the tool-call breakdown from the session logs.
#
# Why interactive (not `claude -p`): headless print-mode picks the
# general-purpose subagent, while real interactive sessions delegate to the
# Explore subagent (or drive codegraph from the main thread). Only the
# interactive TUI reproduces the behavior users actually see. (Idle-detection
# technique borrowed from devpit's WaitForIdle.)
#
# Usage: itrun.sh <repo-path> <label> "<prompt>"
# Output dir: $AGENT_EVAL_OUT (default /tmp/agent-eval)
# Requires: tmux 3.0+, a logged-in `claude` CLI, codegraph MCP configured.
set -uo pipefail
REPO="$1"; LABEL="$2"; PROMPT="$3"
SESSION="cgt_${LABEL}"
OUT_DIR="${AGENT_EVAL_OUT:-/tmp/agent-eval}"; mkdir -p "$OUT_DIR"
OUT="$OUT_DIR/itrun-${LABEL}.txt"
HERE="$(cd "$(dirname "$0")" && pwd)"
cap() { tmux capture-pane -p -t "$SESSION" -S -40; }
tmux kill-session -t "$SESSION" 2>/dev/null
# Wide pane so the TUI doesn't hard-wrap tool lines.
tmux new-session -d -s "$SESSION" -x 230 -y 60
tmux send-keys -t "$SESSION" "cd $REPO && claude --dangerously-skip-permissions ${CLAUDE_EXTRA_ARGS:-}" Enter
# Wait for the prompt (claude drew its UI), up to 60s. NOTE: appears on the
# welcome screen seconds before the input actually accepts keystrokes, so this is
# necessary but NOT sufficient — the type-and-verify loop below is what proves
# the input is live.
ready=0
for _ in $(seq 1 120); do
cap | grep -q "" && { ready=1; break; }
sleep 0.5
done
[ "$ready" = 1 ] || { echo "claude never drew its UI"; cap; tmux kill-session -t "$SESSION" 2>/dev/null; exit 1; }
# Accept the per-folder "Is this a project you trust?" dialog if it shows (first
# time claude opens a given repo). Option 1 ("Yes, I trust this folder") is
# pre-selected, so Enter accepts. This dialog also contains , so it must be
# cleared before the type-and-verify loop or keystrokes land on the menu.
for _ in $(seq 1 20); do
cap | grep -q "trust this folder" || break
tmux send-keys -t "$SESSION" Enter
sleep 1
done
# Type-and-verify: send the prompt, confirm a distinctive chunk of it actually
# landed in the input box, retry if it didn't (handles the early- race where
# the welcome screen shows the prompt glyph but MCP init is still eating keys).
needle="${PROMPT:0:24}"
typed=0
for _ in $(seq 1 30); do
tmux send-keys -l -t "$SESSION" "$PROMPT"
sleep 1
if cap | grep -Fq "$needle"; then typed=1; break; fi
# Clear whatever partial text may have landed, then retry.
tmux send-keys -t "$SESSION" C-u
sleep 1
done
[ "$typed" = 1 ] || { echo "prompt never landed in the input box"; cap; tmux kill-session -t "$SESSION" 2>/dev/null; exit 1; }
sleep 0.5
tmux send-keys -t "$SESSION" Enter
# Busy signals. The robust one is the spinner's elapsed-time-in-parens, which
# EVERY working state shows — both the pre-stream thinking phase
# "(8s · thinking with max effort)" and the streaming phase
# "(24s · ↑ 2.5k tokens · …)", and it survives the 32s→"1m 3s" rollover. We OR
# in the token arrows, "esc to interrupt", and "Initializing" as belt-and-braces
# (some TUI versions/states show one but not the others).
BUSY_RE='esc to interrupt|↓ [0-9]|↑ [0-9]|Initializing|\(([0-9]+m )?[0-9]+s ·'
# Wait for work to START (busy indicator appears), up to 60s. If it never starts,
# fail loudly rather than silently reporting an empty run.
started=0
for _ in $(seq 1 120); do
cap | grep -qE "$BUSY_RE" && { started=1; break; }
sleep 0.5
done
[ "$started" = 1 ] || { echo "agent never started working"; cap; tmux kill-session -t "$SESSION" 2>/dev/null; exit 1; }
# Poll for idle. CRITICAL: Opus 4.8 (extended thinking) renders NO spinner /
# "esc to interrupt" / timer while it STREAMS its final answer — those appear
# only during the thinking + tool-use phases ("✻ Marinating… (32s · ↓ 1.3k
# tokens · thinking with max effort)"). So BUSY_RE reads "not busy" for the whole
# 10-30s answer stream, and any short not-busy threshold kills the run mid-answer
# (the truncation bug). We therefore detect "done" by CONTENT STABILITY, not by a
# spinner string: while the agent streams, the captured pane changes every poll,
# so stability never accrues; it accrues only once the agent has finished and the
# static "✻ Brewed for 1m 9s" summary is all that is left. BUSY_RE still hard-
# resets stability (covers thinking/tool-use/live-timer, where text can briefly
# sit still). Need STABLE_NEEDED polls (~8s) of zero pane change + present.
# Content-stability is model-agnostic — it survives future spinner re-wordings.
STABLE_NEEDED=16
prev=""; stable=0
for _ in $(seq 1 2400); do # up to ~20 min
pane="$(cap)"
sig="$(printf '%s' "$pane" | tr -s '[:space:]' ' ')"
if printf '%s' "$pane" | grep -qE "$BUSY_RE"; then
stable=0 # thinking / tool use / live timer → busy
elif [ -n "$sig" ] && [ "$sig" = "$prev" ] && printf '%s' "$pane" | grep -q ""; then
stable=$((stable+1)); [ "$stable" -ge "$STABLE_NEEDED" ] && break
else
stable=0 # answer still streaming → pane changing
fi
prev="$sig"
sleep 0.5
done
sleep 1
tmux capture-pane -p -t "$SESSION" -S - > "$OUT"
echo "captured $(wc -l < "$OUT") lines -> $OUT"
grep -oE "Done \([^)]*\)|[A-Z][a-z]+ for ([0-9]+m )?[0-9]+s" "$OUT" | tail -1
grep -oE "[0-9.]+k?/[0-9.]+M" "$OUT" | tail -1 | sed 's/^/Context /'
tmux kill-session -t "$SESSION" 2>/dev/null
# Clean tool breakdown from the session logs (main + subagents).
node "$HERE/parse-session.mjs" "$REPO" 2>/dev/null || true
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# 3-arm offload eval for ONE indexed repo + ONE question, n reps each.
# ARM offload : codegraph attached, managed offload ON (per-run AI usage log)
# ARM raw : codegraph attached, CODEGRAPH_OFFLOAD_DISABLE=1 (raw source)
# ARM nocg : no codegraph (empty MCP config) -> Read/Grep baseline
# All arms: claude -p sonnet --effort high. One JSON metrics line/run -> $RESULTS.
#
# Usage: offload-eval-3arm.sh <indexed-repo> <tier> <reps> "<question>"
# Env: MODEL=sonnet EFFORT=high RESULTS=<file> AGENT_EVAL_OUT=<scratch dir>
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ENGINE="$(cd "$HERE/../.." && pwd)"
BIN="$ENGINE/dist/bin/codegraph.js"
OUT="${AGENT_EVAL_OUT:-/tmp/cg-offload-eval}"
TARGET="${1:?usage: offload-eval-3arm.sh <indexed-repo> <tier> <reps> \"<question>\"}"
TIER="${2:?tier}"; REPS="${3:?reps}"; Q="${4:?question}"
RUNS="$OUT/runs"
EXTRACT="$HERE/offload-eval-metrics.mjs"
RESULTS="${RESULTS:-$OUT/results.jsonl}"
REPO=$(basename "$TARGET")
mkdir -p "$RUNS"
command -v claude >/dev/null || { echo "no claude on PATH"; exit 1; }
[ -d "$TARGET/.codegraph" ] || { echo "not indexed: $TARGET (run offload-eval-setup.sh first)"; exit 1; }
# Physical path so pkill matches the daemon's real cmdline (macOS /tmp->/private/tmp symlink
# otherwise makes the kill miss the daemon, and the next arm connects to the SURVIVING daemon
# — contaminating the raw arm with offload).
TARGET=$(cd "$TARGET" && pwd -P)
prewarm() { # path extra-env (e.g. "FOO=bar")
pkill -9 -f "serve --mcp --path $1" 2>/dev/null; rm -f "$1/.codegraph/daemon.sock" 2>/dev/null; sleep 0.6
env ${2:-} CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS=1800000 node "$BIN" serve --mcp --path "$1" </dev/null >/dev/null 2>&1 &
node -e 'const fs=require("fs");let n=0;const t=setInterval(()=>{if(fs.existsSync(process.argv[1]+"/.codegraph/daemon.sock")){clearInterval(t);process.exit(0)}if(n++>150){clearInterval(t);process.exit(1)}},100)' "$1" \
&& echo " daemon warm" || echo " WARN daemon never bound"
}
run() { # arm rep mcp-config usage-log-or-dash
local arm="$1" rep="$2" cfg="$3" usage="$4" tag="$REPO-$1-$2"
[ "$usage" != "-" ] && : > "$usage"
# DISALLOW (optional): block sub-agent delegation across all arms so the A/B
# measures the retrieval mode, not whether Sonnet decides to spawn a codegraph-blind
# Explore subagent (which thrashes regardless and adds huge variance).
( cd "$TARGET" && claude -p "$Q" \
--output-format stream-json --verbose --permission-mode bypassPermissions \
--model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 \
${DISALLOW:+--disallowedTools "$DISALLOW"} \
--strict-mcp-config --mcp-config "$cfg" \
</dev/null > "$RUNS/$tag.jsonl" 2>"$RUNS/$tag.err" )
node "$EXTRACT" --run "$RUNS/$tag.jsonl" --usage "$usage" --arm "$arm" --rep "$rep" \
--repo "$REPO" --tier "$TIER" --q "$Q" >> "$RESULTS"
node -e 'const o=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8").trim().split("\n").pop());console.log(` [${o.arm} #${o.rep}] ${o.durationSec}s | main $${o.costUsdMain} ${o.tokBillable} tok | read=${o.read} grep=${o.grep} explore=${o.explore} offload=${o.offloadFired} | AI ${o.ai.calls}call/${o.ai.totalTokens}tok/$${o.ai.costUsd.toFixed(4)} | ok=${o.ok}`)' "$RESULTS"
}
CFG_OFF="$RUNS/mcp-offload-$REPO.json"; CFG_RAW="$RUNS/mcp-raw-$REPO.json"; CFG_NOCG="$RUNS/mcp-nocg.json"
USAGE="$RUNS/$REPO-usage.jsonl"
printf '{"mcpServers":{"codegraph":{"command":"env","args":["CODEGRAPH_WASM_RELAUNCHED=1","CODEGRAPH_OFFLOAD_USAGE_LOG=%s","node","%s","serve","--mcp","--path","%s"]}}}' "$USAGE" "$BIN" "$TARGET" > "$CFG_OFF"
printf '{"mcpServers":{"codegraph":{"command":"env","args":["CODEGRAPH_WASM_RELAUNCHED=1","CODEGRAPH_OFFLOAD_DISABLE=1","node","%s","serve","--mcp","--path","%s"]}}}' "$BIN" "$TARGET" > "$CFG_RAW"
printf '{"mcpServers":{}}' > "$CFG_NOCG"
# REP_START lets a later batch ADD reps without clobbering earlier jsonls
# (e.g. REP_START=4 REPS=3 -> reps 4,5,6; default starts at 1).
START="${REP_START:-1}"; END=$((START + REPS - 1))
echo "###### repo=$REPO tier=$TIER reps=$START..$END model=${MODEL:-sonnet}/${EFFORT:-high}"
echo "###### Q=$Q"
echo "== ARM offload =="; prewarm "$TARGET" "CODEGRAPH_OFFLOAD_USAGE_LOG=$USAGE"
for r in $(seq "$START" "$END"); do run offload "$r" "$CFG_OFF" "$USAGE"; done
pkill -9 -f "serve --mcp --path $TARGET" 2>/dev/null; rm -f "$TARGET/.codegraph/daemon.sock" 2>/dev/null; sleep 1
echo "== ARM raw =="; prewarm "$TARGET" "CODEGRAPH_OFFLOAD_DISABLE=1"
for r in $(seq "$START" "$END"); do run raw "$r" "$CFG_RAW" "-"; done
pkill -9 -f "serve --mcp --path $TARGET" 2>/dev/null; rm -f "$TARGET/.codegraph/daemon.sock" 2>/dev/null; sleep 1
echo "== ARM nocg =="
for r in $(seq "$START" "$END"); do run nocg "$r" "$CFG_NOCG" "-"; done
echo "###### DONE $REPO"
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env node
// Cost/token analysis for the 3-arm offload eval, with a MAIN-vs-SUBAGENT split.
//
// The explore-subagent question. With delegation ALLOWED, the nocg arm spawns a
// Claude Code Explore subagent; the codegraph arms do all work in the main agent.
// Two facts make naive accounting wrong:
// 1. The Explore subagent runs on HAIKU 4.5; the main agent on SONNET 4.6.
// So per-token cost differs ~3x between them — you cannot price both the same.
// 2. The subagent's consumption is ~95% cache-reads. At Haiku's $0.10/MTok
// cache-read rate, a huge TOKEN volume is a small DOLLAR cost.
//
// Rather than re-derive cost from raw token counts (and guess the cache TTL —
// Claude Code uses 1-hour ephemeral cache here, 2x write, not 5-min), we read
// Claude Code's OWN authoritative accounting from the `result` event:
// result.modelUsage[model].costUSD — per-model cost CC itself billed
// result.total_cost_usd — their sum (INCLUDES the Haiku subagent;
// the handoff's "excludes subagent" was wrong)
// The model split IS the agent split here: sonnet => main, haiku => Explore subagent
// (only nocg spawns one, and only nocg shows haiku usage). Token volume is still
// summed per-model from modelUsage for the separate "tokens" story.
//
// Usage: offload-eval-cost.mjs <runs-dir> <repo> [reps]
// e.g. offload-eval-cost.mjs /tmp/cg-offload-eval/runs trezor 3
import { readFileSync, existsSync } from 'fs';
const MAIN_TIER = /sonnet/; // main agent
const SUB_TIER = /haiku/; // Claude Code Explore subagent
const [,, runsDir, repo, repsArg] = process.argv;
if (!runsDir || !repo) { console.error('usage: offload-eval-cost.mjs <runs-dir> <repo> [reps] (env ARMS=nocg,raw,offload)'); process.exit(1); }
const REPS = Number(repsArg || 3);
// Arms to analyze (file stems `<repo>-<arm>-<rep>.jsonl`). Override for the style A/B:
// ARMS=raw,refs,map,src. nocg's Haiku subagent is the only sub-tier; the rest are main-only.
const ARMS = (process.env.ARMS || 'nocg,raw,offload').split(',').map((s) => s.trim()).filter(Boolean);
const toks = (u) => (u.inputTokens||0)+(u.outputTokens||0)+(u.cacheReadInputTokens||0)+(u.cacheCreationInputTokens||0);
function analyzeRun(file) {
let result = null, agentCalls = 0;
const tools = {}, subPids = new Set();
for (const line of readFileSync(file, 'utf8').split('\n')) {
if (!line) continue;
let e; try { e = JSON.parse(line); } catch { continue; }
if (e.parent_tool_use_id && e.message?.usage) subPids.add(e.parent_tool_use_id);
if (e.type === 'assistant' && Array.isArray(e.message?.content))
for (const b of e.message.content)
if (b.type === 'tool_use') { tools[b.name] = (tools[b.name]||0)+1; if (b.name === 'Agent') agentCalls++; }
if (e.type === 'result') result = e;
}
// Authoritative cost + tokens from Claude Code's per-model accounting.
const mu = result?.modelUsage || {};
const main = { cost: 0, tok: 0 }, sub = { cost: 0, tok: 0 };
for (const [model, u] of Object.entries(mu)) {
const bucket = SUB_TIER.test(model) ? sub : main; // sonnet/anything-else => main
bucket.cost += u.costUSD || 0;
bucket.tok += toks(u);
}
return {
main, sub, subagents: subPids.size, agentCalls,
ccTotal: result?.total_cost_usd ?? null,
ok: result?.subtype === 'success',
durationSec: result?.duration_ms ? +(result.duration_ms/1000).toFixed(1) : null,
models: Object.keys(mu), tools,
};
}
const k = (n) => (n/1000).toFixed(0).padStart(5) + 'K';
const d = (n) => '$' + n.toFixed(3);
const cost = (b) => b.cost;
const tot = (b) => b.tok;
const byArm = {};
for (const arm of ARMS) {
const runs = [];
for (let r = 1; r <= REPS; r++) {
const f = `${runsDir}/${repo}-${arm}-${r}.jsonl`;
if (existsSync(f)) runs.push({ rep: r, ...analyzeRun(f) });
}
byArm[arm] = runs;
}
// Per-run detail. Cost is Claude Code's own modelUsage.costUSD (authoritative,
// per-model pricing + correct cache TTL). MAIN=Sonnet, SUB=Haiku Explore subagent.
// cc-check: main$+sub$ must equal result.total_cost_usd (delta should be ~0).
console.log(`\n=== ${repo}: per-run main(Sonnet)/sub(Haiku) split — Claude Code's own cost accounting ===`);
console.log('arm rep | subAg | MAIN(sonnet) tok / $ | SUB(haiku) tok / $ | TOTAL tok / $ | cc_total Δ | dur reads');
for (const arm of ARMS) for (const r of byArm[arm]) {
const mC = cost(r.main), sC = cost(r.sub), mT = tot(r.main), sT = tot(r.sub);
const reads = r.tools['Read'] || 0, grep = (r.tools['Grep']||0)+(r.tools['Bash']||0)+(r.tools['Glob']||0);
const explore = r.tools['mcp__codegraph__codegraph_explore'] || 0;
const delta = (mC + sC) - (r.ccTotal || 0); // should be ~0
console.log(
`${arm.padEnd(8)} #${r.rep} | ${String(r.subagents).padStart(2)} | ${k(mT)} ${d(mC).padStart(7)} | ${k(sT)} ${d(sC).padStart(7)} | ${k(mT+sT)} ${d(mC+sC).padStart(7)} | ${d(r.ccTotal||0).padStart(7)} ${(delta>=0?'+':'')+delta.toFixed(4)} | ${String(r.durationSec).padStart(5)} r=${reads} g=${grep} x=${explore}`
);
}
// Per-arm means
const mean = (arr, f) => arr.length ? arr.reduce((s,x)=>s+f(x),0)/arr.length : 0;
console.log(`\n=== ${repo}: per-arm MEANS (n per arm) ===`);
console.log('arm n | main $ sub $ TOTAL $ | main tok sub tok TOTAL tok | %$ in sub | %tok in sub');
for (const arm of ARMS) {
const runs = byArm[arm]; if (!runs.length) continue;
const mC = mean(runs, r=>cost(r.main)), sC = mean(runs, r=>cost(r.sub));
const mT = mean(runs, r=>tot(r.main)), sT = mean(runs, r=>tot(r.sub));
const pctSubC = (mC+sC) ? (100*sC/(mC+sC)) : 0;
const pctSubT = (mT+sT) ? (100*sT/(mT+sT)) : 0;
console.log(
`${arm.padEnd(8)} ${runs.length} | ${d(mC).padStart(7)} ${d(sC).padStart(7)} ${d(mC+sC).padStart(7)} | ${k(mT)} ${k(sT)} ${k(mT+sT)} | ${pctSubC.toFixed(0).padStart(3)}% | ${pctSubT.toFixed(0).padStart(3)}%`
);
}
// Headline ladders — cost, tokens, duration, all vs a baseline (nocg if present, else first arm).
console.log(`\n=== Ladders (mean, incl. subagent) ===`);
const totals = ARMS.map(a => ({ a, c: mean(byArm[a], r=>cost(r.main)+cost(r.sub)), t: mean(byArm[a], r=>tot(r.main)+tot(r.sub)) })).filter(x=>byArm[x.a].length);
const base = totals.find(x=>x.a==='nocg') ?? totals[0];
const bn = base?.a ?? '?';
console.log(` COST (vs ${bn}):`);
for (const x of totals) {
const vs = base && base.c ? ` (${((x.c/base.c-1)*100>=0?'+':'')}${((x.c/base.c-1)*100).toFixed(0)}%)` : '';
console.log(` ${x.a.padEnd(8)} ${d(x.c)}${vs}`);
}
console.log(` TOKENS (vs ${bn}):`);
for (const x of totals) {
const vs = base && base.t ? ` (${((x.t/base.t-1)*100>=0?'+':'')}${((x.t/base.t-1)*100).toFixed(0)}%)` : '';
console.log(` ${x.a.padEnd(8)} ${k(x.t)}${vs}`);
}
console.log(` DURATION (wall-clock, vs ${bn}):`);
const durs = ARMS.map(a => ({ a, s: mean(byArm[a].filter(r=>r.durationSec!=null), r=>r.durationSec) })).filter(x=>byArm[x.a].length);
const dbase = durs.find(x=>x.a==='nocg') ?? durs[0];
for (const x of durs) {
const vs = dbase && dbase.s ? ` (${((x.s/dbase.s-1)*100>=0?'+':'')}${((x.s/dbase.s-1)*100).toFixed(0)}%)` : '';
console.log(` ${x.a.padEnd(8)} ${x.s.toFixed(0)}s${vs}`);
}
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env node
// Effort A/B — does CODEGRAPH_OFFLOAD_EFFORT=high improve offload SYNTHESIS FIDELITY vs low?
// Probe-based (no agent): for each repo × effort × rep, run codegraph_explore with the offload
// ON on the canonical question, capture the synthesized answer + AI tokens/cost/latency, then
// Sonnet-judge that answer's fidelity vs source-verified ground truth. Isolates the synthesis
// from agent/adoption noise. Requires `codegraph login` (managed offload) + indexed repos.
//
// Env: REPS (default 3) · CG_ENGINE (engine repo) · AGENT_EVAL_OUT (repos under /repos) · CONC (judge concurrency)
import { pathToFileURL, fileURLToPath } from 'node:url';
import { resolve, dirname, join } from 'node:path';
import { readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs';
import { execFile } from 'node:child_process';
import { tmpdir } from 'node:os';
const HERE = dirname(fileURLToPath(import.meta.url));
const ENGINE = process.env.CG_ENGINE || resolve(HERE, '..', '..');
const OUT = process.env.AGENT_EVAL_OUT || '/tmp/cg-offload-eval';
const REPOS = join(OUT, 'repos');
const GT = JSON.parse(readFileSync(resolve(HERE, 'offload-eval-ground-truth.json'), 'utf8'));
const REPS = Number(process.env.REPS || 3);
const CONC = Number(process.env.CONC || 4);
const EFFORTS = (process.env.EFFORTS_FILTER || 'low,high').split(',');
const ONLY = process.env.REPOS_FILTER ? new Set(process.env.REPOS_FILTER.split(',')) : null;
const TIER = { mtkruto: 'small', postybirb: 'medium', shapeshift: 'complex', trezor: 'large' };
const load = async (rel) => import(pathToFileURL(resolve(ENGINE, rel)).href);
const idx = await load('dist/index.js');
const toolsMod = await load('dist/mcp/tools.js');
const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph;
const ToolHandler = toolsMod.ToolHandler ?? toolsMod.default?.ToolHandler;
if (typeof CodeGraph?.openSync !== 'function' || typeof ToolHandler !== 'function') {
console.error('could not load engine from', ENGINE); process.exit(2);
}
const fidPrompt = (gt, ans) => `You are scoring the FIDELITY of a machine-synthesized code-exploration answer against verified ground truth. Do NOT use any tools.
QUESTION: ${gt.question}
VERIFIED GROUND TRUTH (the actual call path + files):
${gt.truth}
SYNTHESIZED ANSWER (to score):
${ans || '(empty)'}
Judge: (1) is the traced call path correct vs ground truth? (2) are the cited files/symbols correct (not fabricated)? (3) if it gave a "Coverage:" verdict, was it honest? A confident WRONG trace is the worst outcome — penalize it harder than an honest partial.
Output ONLY minified JSON: {"verdict":"pass|partial|fail","score":<0-100>,"fabrication":<true|false>,"coverageHonest":<true|false>,"note":"<=20 words"}`;
const askJudge = (prompt) => new Promise((res) => {
execFile('claude', ['-p', prompt, '--model', 'sonnet', '--effort', 'high', '--max-budget-usd', '0.5',
'--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}'],
{ cwd: OUT, maxBuffer: 1 << 24, timeout: 120000 }, (err, stdout) => {
const m = (stdout || '').match(/\{[\s\S]*\}/);
if (!m) return res({ verdict: 'error', score: null, note: (err ? err.message : 'no json').slice(0, 60) });
try { res(JSON.parse(m[0])); } catch { res({ verdict: 'error', score: null }); }
});
});
// ---- 1. Probe: collect synthesized answers at each effort -------------------
const records = [];
for (const repo of Object.keys(GT)) {
if (ONLY && !ONLY.has(repo)) continue;
const dir = join(REPOS, repo);
if (!existsSync(join(dir, '.codegraph'))) { console.error('skip (not indexed):', repo); continue; }
const cg = CodeGraph.openSync(dir);
const h = new ToolHandler(cg);
for (const effort of EFFORTS) {
for (let rep = 1; rep <= REPS; rep++) {
process.env.CODEGRAPH_OFFLOAD_EFFORT = effort;
const usageLog = join(tmpdir(), `effort-${repo}-${effort}-${rep}.jsonl`);
try { rmSync(usageLog); } catch { /* none */ }
process.env.CODEGRAPH_OFFLOAD_USAGE_LOG = usageLog;
let answer = '';
try { answer = (await h.execute('codegraph_explore', { query: GT[repo].question }))?.content?.[0]?.text ?? ''; }
catch (e) { console.error(` ${repo}/${effort}#${rep} explore failed: ${e?.message}`); }
const fired = /Synthesized by CodeGraph/.test(answer);
const ai = { tokens: 0, cost: 0, ms: 0 };
if (existsSync(usageLog)) for (const e of readFileSync(usageLog, 'utf8').split('\n').filter(Boolean).map(JSON.parse)) {
ai.tokens += e.totalTokens || 0; ai.cost += e.costUsd || 0; ai.ms += e.ms || 0;
}
records.push({ repo, tier: TIER[repo], effort, rep, fired, ai, answer });
console.error(` ${repo}/${effort}#${rep}: fired=${fired} ${ai.tokens}tok $${ai.cost.toFixed(4)} ${ai.ms}ms`);
}
}
try { cg.close?.(); } catch { /* none */ }
}
// ---- 2. Judge fidelity (concurrency) ---------------------------------------
console.error(`\njudging ${records.length} answers (concurrency ${CONC})...`);
let done = 0;
const q = [...records];
async function worker() { while (q.length) { const r = q.shift(); r.fid = await askJudge(fidPrompt(GT[r.repo], r.answer)); console.error(` [${++done}/${records.length}] ${r.repo}/${r.effort}#${r.rep}: ${r.fid.verdict} ${r.fid.score ?? ''}`); } }
await Promise.all(Array.from({ length: CONC }, worker));
writeFileSync(join(OUT, 'effort-results.jsonl'), records.map((r) => JSON.stringify(r)).join('\n') + '\n');
// ---- 3. Aggregate: low vs high per repo ------------------------------------
const med = (a) => { a = a.filter((x) => x != null).sort((x, y) => x - y); return a.length ? (a.length % 2 ? a[(a.length - 1) / 2] : (a[a.length / 2 - 1] + a[a.length / 2]) / 2) : null; };
console.log(`\n${'='.repeat(80)}\nEFFORT A/B — offload synthesis fidelity (probe, n=${REPS}/cell)\n${'='.repeat(80)}`);
console.log(`${'repo'.padEnd(11)} ${'tier'.padEnd(8)} ${'effort'.padEnd(6)} fired ${'fid(med)'.padStart(8)} ${'fab%'.padStart(5)} ${'AItok'.padStart(7)} ${'AIcost'.padStart(8)} ${'ms(med)'.padStart(8)}`);
for (const repo of Object.keys(GT)) {
for (const effort of EFFORTS) {
const rs = records.filter((r) => r.repo === repo && r.effort === effort);
if (!rs.length) continue;
const fids = rs.map((r) => r.fid?.score).filter((x) => x != null);
const fab = rs.filter((r) => r.fid?.fabrication === true).length;
console.log(`${repo.padEnd(11)} ${TIER[repo].padEnd(8)} ${effort.padEnd(6)} ${rs.filter((r) => r.fired).length}/${rs.length} ${String(med(fids) ?? '—').padStart(8)} ${String(Math.round(100 * fab / rs.length) + '%').padStart(5)} ${String(Math.round(med(rs.map((r) => r.ai.tokens)) / 1000) + 'k').padStart(7)} ${('$' + (med(rs.map((r) => r.ai.cost)) ?? 0).toFixed(4)).padStart(8)} ${String(med(rs.map((r) => r.ai.ms)) ?? '—').padStart(8)}`);
}
}
console.log('');
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Run the FRONTLOAD arm across all 4 tiers (n reps), then judge + merge with the existing
# matrix (offload/raw/nocg in $OUT/judged.jsonl, if present) + emit a combined summary.
# Env: REPS (default 3) AGENT_EVAL_OUT=<scratch dir>
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
OUT="${AGENT_EVAL_OUT:-/tmp/cg-offload-eval}"
GT="$HERE/offload-eval-ground-truth.json"
REPS="${REPS:-3}"
export RESULTS="$OUT/results-fl.jsonl"
: > "$RESULTS"; rm -f "$OUT/runs/hook-debug.log"
for repo in mtkruto postybirb shapeshift trezor; do
case "$repo" in mtkruto) tier=small;; postybirb) tier=medium;; shapeshift) tier=complex;; trezor) tier=large;; esac
Q=$(node -e "console.log(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))[process.argv[2]].question)" "$GT" "$repo")
echo ""; echo "### $repo ($tier) $(date +%H:%M:%S)"
bash "$HERE/offload-eval-frontload.sh" "$OUT/repos/$repo" "$tier" "$REPS" "$Q"
done
echo ""
echo "frontload: $(wc -l < "$RESULTS") runs | hook injections: $(grep -c INJECTED "$OUT/runs/hook-debug.log" 2>/dev/null) | errors: $(grep -c ERROR "$OUT/runs/hook-debug.log" 2>/dev/null)"
echo "=== JUDGE frontload ==="
node "$HERE/offload-eval-judge.mjs" --results "$RESULTS" --truth "$GT" --out "$OUT/judged-fl.jsonl" --concurrency 4 2>&1 | tail -4
if [ -f "$OUT/judged.jsonl" ]; then cat "$OUT/judged.jsonl" "$OUT/judged-fl.jsonl" > "$OUT/judged-all.jsonl"; else cp "$OUT/judged-fl.jsonl" "$OUT/judged-all.jsonl"; fi
echo "=== COMBINED SUMMARY ==="
node "$HERE/offload-eval-summarize.mjs" "$OUT/judged-all.jsonl"
echo "###### FRONTLOAD MATRIX DONE"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# FRONTLOAD arm (approach 1): codegraph attached (offload-disabled) + the front-load
# UserPromptSubmit hook (offload-eval-hook.mjs), n reps, appended to $RESULTS. Compare against
# the matrix's raw/nocg baselines. Usage: offload-eval-frontload.sh <indexed-repo> <tier> <reps> "<Q>"
# Env: MODEL=sonnet EFFORT=high RESULTS=<file> AGENT_EVAL_OUT=<scratch dir>
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ENGINE="$(cd "$HERE/../.." && pwd)"
BIN="$ENGINE/dist/bin/codegraph.js"
OUT="${AGENT_EVAL_OUT:-/tmp/cg-offload-eval}"
TARGET="${1:?repo}"; TIER="${2:?tier}"; REPS="${3:?reps}"; Q="${4:?question}"
RUNS="$OUT/runs"
EXTRACT="$HERE/offload-eval-metrics.mjs"
RESULTS="${RESULTS:-$OUT/results-fl.jsonl}"
REPO=$(basename "$TARGET")
mkdir -p "$RUNS"
[ -d "$TARGET/.codegraph" ] || { echo "not indexed: $TARGET"; exit 1; }
TARGET=$(cd "$TARGET" && pwd -P)
CFG="$RUNS/mcp-fl-$REPO.json"
printf '{"mcpServers":{"codegraph":{"command":"env","args":["CODEGRAPH_WASM_RELAUNCHED=1","CODEGRAPH_OFFLOAD_DISABLE=1","node","%s","serve","--mcp","--path","%s"]}}}' "$BIN" "$TARGET" > "$CFG"
# Generate the hook settings pointing at the persisted hook; enable its debug log so we can
# count injections (claude passes this env down to the spawned hook process).
HOOKCFG="$RUNS/frontload-settings.json"
printf '{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"node %s/offload-eval-hook.mjs"}]}]}}' "$HERE" > "$HOOKCFG"
export CG_FRONTLOAD_DEBUG="$RUNS/hook-debug.log"
prewarm() {
pkill -9 -f "serve --mcp --path $1" 2>/dev/null; rm -f "$1/.codegraph/daemon.sock" 2>/dev/null; sleep 0.6
env CODEGRAPH_OFFLOAD_DISABLE=1 CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS=1800000 node "$BIN" serve --mcp --path "$1" </dev/null >/dev/null 2>&1 &
node -e 'const fs=require("fs");let n=0;const t=setInterval(()=>{if(fs.existsSync(process.argv[1]+"/.codegraph/daemon.sock")){clearInterval(t);process.exit(0)}if(n++>150){clearInterval(t);process.exit(1)}},100)' "$1" \
&& echo " daemon warm" || echo " WARN no daemon"
}
echo "###### FRONTLOAD repo=$REPO tier=$TIER reps=$REPS"
prewarm "$TARGET"
for r in $(seq 1 "$REPS"); do
tag="$REPO-frontload-$r"
( cd "$TARGET" && claude -p "$Q" --output-format stream-json --verbose --permission-mode bypassPermissions \
--model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 \
--strict-mcp-config --mcp-config "$CFG" --settings "$HOOKCFG" \
</dev/null > "$RUNS/$tag.jsonl" 2>"$RUNS/$tag.err" )
node "$EXTRACT" --run "$RUNS/$tag.jsonl" --usage "-" --arm frontload --rep "$r" --repo "$REPO" --tier "$TIER" --q "$Q" >> "$RESULTS"
node -e 'const o=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8").trim().split("\n").pop());console.log(` [frontload #${o.rep}] ${o.durationSec}s | main $${o.costUsdMain} ${o.tokBillable}tok | read=${o.read} grep=${o.grep} agentExplore=${o.explore} | ok=${o.ok}`)' "$RESULTS"
done
pkill -9 -f "serve --mcp --path $TARGET" 2>/dev/null; rm -f "$TARGET/.codegraph/daemon.sock" 2>/dev/null
echo "###### FRONTLOAD DONE $REPO (cumulative hook injections: $(grep -c INJECTED "$CG_FRONTLOAD_DEBUG" 2>/dev/null))"
@@ -0,0 +1,18 @@
{
"mtkruto": {
"question": "How does calling the high-level client.sendMessage() method get the message serialized into a TL/MTProto request and sent over the network transport to Telegram's servers? Trace the call path.",
"truth": "Verified call path (small TS Telegram-client lib):\n1. Client.sendMessage — client/6_client.ts:1852 → calls this.#messageManager.sendMessage(...) (1853)\n2. MessageManager.sendMessage — client/3_message_manager.ts:330 → builds the TL function {_:\"messages.sendMessage\", peer, message,...} and calls this.#c.invoke(...) (~377)\n3. c.invoke closure / Client.#invoke — client/6_client.ts:279/887 → resolves a ClientEncrypted and calls client.invoke(function_) (897)\n4. ClientEncrypted.invoke — client/2_client_encrypted.ts:324 → this.#send(function_) (325)\n5. ClientEncrypted.#send — client/2_client_encrypted.ts:261 → SERIALIZES via Api.serializeObject(function_) (290), then this.session.send(body) (296)\n - Serialization: Api.serializeObject (tl/2_telegram.ts:38) → new TLWriter().writeObject(...) (tl/1_tl_writer.ts) writes constructor id + fields\n6. SessionEncrypted.send — session/2_session_encrypted.ts:193 → ENQUEUES a PendingMessage and wakes the send loop (#awakeSendLoop) [DYNAMIC: async queue, not a direct call]\n7. SessionEncrypted.#sendLoopBody (AbortableLoop) — session/2_session_encrypted.ts:282 → #encryptMessage (serializeMessage tl/2_message.ts + IGE-256 AES) then this.transport.transport.send(payload) (339)\n8. Transport.send — TransportAbridged.send (transport/1_transport_abridged.ts:71) or TransportIntermediate → this.#connection.write(encrypt(framed))\n9. ConnectionTCP.write (connection/1_connection_tcp.ts:96) or ConnectionWebSocket.write — bytes exit the process.\nKEY SYMBOLS a correct answer must hit: sendMessage → MessageManager.sendMessage → invoke (ClientEncrypted.invoke/#send) → Api.serializeObject/TLWriter → SessionEncrypted.send (queue) → #sendLoopBody/#encryptMessage → Transport.send → Connection.write.\nDYNAMIC BOUNDARIES: the invoke indirection (closure→#invoke→ClientEncrypted), and the send-loop QUEUE between SessionEncrypted.send and #sendLoopBody (async, no direct edge)."
},
"postybirb": {
"question": "How does submitting/queueing a post reach the website-specific code that actually uploads to a target website? Trace the path from the submission entry point in the NestJS server to a concrete website service's post implementation.",
"truth": "Verified call path (NestJS+Electron, server under electron-app/src/server/):\n1. Entry: PostController.queue (submission/post/post.controller.ts:33, POST queue/:id) OR SubmissionService.queueScheduledSubmissions (submission/submission.service.ts, @Interval(60000) scheduler) — both call PostService.queue\n2. PostService.queue — submission/post/post.service.ts:68 → this.post(submission)\n3. PostService.post (private) — post.service.ts:206 → this.createPoster(...) for each non-default SubmissionPart\n4. PostService.createPoster — post.service.ts:373 → website = this.websites.getWebsiteModule(part.website) [part.website is a STRING]\n5. WebsiteProvider.getWebsiteModule — websites/website-provider.service.ts:86 → websiteModulesMap[name.toLowerCase()] [DYNAMIC: string-keyed registry of DI-injected Website singletons]; then new Poster(..., website, ...)\n6. Poster constructor — submission/post/poster.ts:117 → setTimeout(this.post.bind(this), delay) [DYNAMIC: async timer, min ~5s]\n7. Poster.post (poster.ts:131) → performPost (171) → attemptPost (217)\n8. Poster.attemptPost — poster.ts:217 → this.website.postFileSubmission(token, data, accountData) [DYNAMIC: polymorphic dispatch on abstract Website] (or postNotificationSubmission)\n9. Website.postFileSubmission (abstract) — websites/website.base.ts:102\n10. Concrete e.g. FurAffinity.postFileSubmission — websites/fur-affinity/fur-affinity.service.ts:231 → multi-step HTTP upload (GET /submit, POST /submit/upload multipart, POST /submit/finalize)\nKEY SYMBOLS: PostService.queue → PostService.post → createPoster → WebsiteProvider.getWebsiteModule (registry) → Poster (setTimeout) → Poster.attemptPost → Website.postFileSubmission (abstract base) → a concrete website service (e.g. FurAffinity).\nDYNAMIC BOUNDARIES: NestJS DI builds the website registry; string-keyed map lookup; setTimeout defers Poster.post; polymorphic dispatch on the abstract Website base. A correct answer must reach a concrete website's post via the registry + base class, not stop at PostService."
},
"shapeshift": {
"question": "How does executing a swap work in ShapeShift — from the code that fetches quotes and selects a swapper down to a specific swapper's execute/trade? Name the swapper interface, the registry, and one concrete swapper, and trace the path.",
"truth": "Verified call path (large multi-package monorepo; swap logic in packages/swapper + execution in src/lib):\nQUOTE/REGISTRY layer:\n- Swapper interface = the `Swapper` type (execute methods) + `SwapperApi` type (getTradeQuote/getUnsignedTx) — packages/swapper/src/types.ts (~846/897)\n- Registry = `swappers: Record<SwapperName,(SwapperApi & Swapper)>` — packages/swapper/src/constants.ts:52 (merges e.g. thorchainSwapper + thorchainApi)\n- Aggregator: swapperApi RTK endpoint getTradeQuote/getTradeRates (src/state/apis/swapper/swapperApi.ts:78/156) → getTradeQuotes (packages/swapper/src/swapper.ts:18/26): swapper = swappers[swapperName]; swapper.getTradeQuote(...) [DYNAMIC interface dispatch]\n- Concrete (THORChain): thorchainApi.getTradeQuote (swappers/ThorchainSwapper/endpoints.ts → getTradeQuote/getTradeQuote.ts:15) → getL1RateOrQuote → getQuote (thorService HTTP)\nSELECTION: tradeQuoteSlice selectors rank quotes (selectSortedTradeQuotes / selectActiveSwapperName) [DYNAMIC: winner chosen by ranking/user]\nEXECUTION layer:\n- useTradeExecution (src/components/MultiHopTrade/.../hooks/useTradeExecution.tsx:200/476) → new TradeExecution(); execution.execEvmTransaction(...) (CowSwap: execEvmMessage)\n- TradeExecution.execEvmTransaction — src/lib/tradeExecution.ts:326 → _execWalletAgnostic(...) (372)\n- TradeExecution._execWalletAgnostic — tradeExecution.ts:136 → swapper = swappers[swapperName] (149) [DYNAMIC registry]; buildSignBroadcast → swapper.getUnsignedEvmTransaction(...) (355) then swapper.executeEvmTransaction(unsignedTx, {signAndBroadcastTransaction}) (367) [DYNAMIC SwapperApi/Swapper interface]\n- Concrete (THORChain): thorchainSwapper.executeEvmTransaction (swappers/ThorchainSwapper/ThorchainSwapper.ts → utils.ts:181) delegates to callbacks.signAndBroadcastTransaction (wallet). CowSwap alt: cowSwapper.executeEvmMessage → signCowOrder + cowService.post.\nKEY SYMBOLS: Swapper/SwapperApi types, swappers registry (constants.ts), getTradeQuotes (swapper.ts), TradeExecution._execWalletAgnostic, swapper.getUnsignedEvmTransaction/executeEvmTransaction, one concrete swapper (thorchainSwapper/zrxSwapper/cowSwapper).\nDYNAMIC BOUNDARIES: swappers[name] registry lookup (2 sites); all hops into a concrete swapper are via the Swapper/SwapperApi interface, never a direct function. A correct answer must name the interface + registry and reach a concrete swapper through interface dispatch."
},
"trezor": {
"question": "How does sending a crypto transaction flow from the send form's review/sign action through to signing it via @trezor/connect (TrezorConnect.signTransaction)? Trace the call path.",
"truth": "Verified call path (trezor-suite monorepo; app in packages/suite, shared logic in suite-common/wallet-core, device API in packages/connect):\n1. ReviewButton.handleButtonReviewClick — packages/suite/src/views/wallet/send/TotalSent/ReviewButton.tsx:120 → signTransaction() (= useSendForm's sign)\n2. useSendForm.sign (exported as signTransaction) — packages/suite/src/hooks/wallet/useSendForm.ts:278 → dispatch(signAndPushSendFormTransactionThunk({formState, precomposedTransaction, selectedAccount})) [DYNAMIC: redux thunk]\n3. signAndPushSendFormTransactionThunk — packages/suite/src/actions/wallet/send/sendFormThunks.ts:237 → (first enhancePrecomposedTransactionThunk) then dispatch(signTransactionThunk({...})) [cross-package: thunk from suite-common/wallet-core]\n4. signTransactionThunk (coin-routing hub) — suite-common/wallet-core/src/send/sendFormThunks.ts:532/573 → networkType branch → dispatch(signBitcoinSendFormTransactionThunk) (ethereum→signEthereumSendFormTransactionThunk, etc.) [DYNAMIC: runtime coin dispatch]\n5. signBitcoinSendFormTransactionThunk — suite-common/wallet-core/src/send/sendFormBitcoinThunks.ts:394 → await TrezorConnect.signTransaction(signPayload)\n6. TrezorConnect.signTransaction (facade) — packages/connect-common/src/factory.ts → closure calls impl.call({method:'signTransaction',...}) [DYNAMIC: facade / iframe-or-module boundary]\n7. CoreInModule.call — packages/connect/src/impl/core-in-module.ts:171 → posts CORE_CALL to core (deferred promise)\n8. Core.onCall → getMethod — packages/connect/src/core/index.ts → getMethod resolves 'signTransaction' → new SignTransaction(message) [DYNAMIC: name→class]\n9. SignTransaction.run — packages/connect/src/api/signTransaction.ts:317 → signTx via device.getCommands().typedCall (protobuf to device). After signing, signAndPushSendFormTransactionThunk → pushSendFormTransactionThunk → TrezorConnect.pushTransaction.\nKEY SYMBOLS: ReviewButton → useSendForm.sign → signAndPushSendFormTransactionThunk (suite) → signTransactionThunk (wallet-core, coin hub) → signBitcoinSendFormTransactionThunk → TrezorConnect.signTransaction → connect factory/CoreInModule.call → SignTransaction.run.\nDYNAMIC BOUNDARIES: every suite→wallet-core hop is a redux thunk dispatch; signTransactionThunk branches by networkType at runtime; TrezorConnect is a dynamically-built facade crossing an iframe/module boundary; the SignTransaction class is resolved by name. A correct answer must cross suite→wallet-core→connect and reach TrezorConnect.signTransaction / SignTransaction.run, not stop at the UI."
}
}
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env node
// UserPromptSubmit hook — APPROACH 1: additive context-injection.
// Front-loads codegraph's structural answer for flow/impact/"how/where" prompts so the
// agent's reflex grep/read has nothing left to find. Strictly additive (never blocks),
// gated to structural prompts (no cost otherwise), and uses RAW explore (offload disabled)
// so the injected context is accurate — never the (currently low-fidelity) synthesis.
//
// Reads {prompt, cwd} as JSON on stdin; prints the explore result to stdout (which Claude
// Code injects into the agent's context). Any failure -> silent exit 0 (degradable).
import { pathToFileURL, fileURLToPath } from 'node:url';
import { resolve, join, dirname } from 'node:path';
import { existsSync, readFileSync, appendFileSync } from 'node:fs';
// Resolve the engine repo from this script's own location (scripts/agent-eval/ -> ../..),
// overridable with CG_ENGINE. The hook ships inside the repo, so it finds its own dist.
const HERE = dirname(fileURLToPath(import.meta.url));
const ENGINE = process.env.CG_ENGINE || resolve(HERE, '..', '..');
const BUDGET = Number(process.env.CG_FRONTLOAD_BUDGET || 16000);
// Debug log only when CG_FRONTLOAD_DEBUG is set to a file path (the harness points it at a
// log to count injections); off by default so the shipped hook writes nothing extra.
const DBG = process.env.CG_FRONTLOAD_DEBUG;
const dbg = (m) => { if (!DBG) return; try { appendFileSync(DBG, `[${new Date().toISOString()}] ${m}\n`); } catch { /* ignore */ } };
let input = {};
try { input = JSON.parse(readFileSync(0, 'utf8')); } catch (e) { dbg('stdin parse fail: ' + e.message); }
const prompt = String(input.prompt || '');
const cwd = String(input.cwd || process.cwd());
dbg(`invoked: promptLen=${prompt.length} cwd=${cwd}`);
// Gate: only structural / flow / impact / where-how questions. Cheap regex; silent no-op
// otherwise so non-structural prompts ("fix this typo") cost nothing.
const STRUCTURAL = /\b(how|where|trace|flow|path|reach(es|ed)?|call(s|ed|er|ers|ee)?|depend|impact|affect|wire[ds]?|connect|implement|architect|structure|breaks?|what calls|why does)\b/i;
if (!prompt || !STRUCTURAL.test(prompt)) { dbg('gate: non-structural, no-op'); process.exit(0); }
dbg('gate: structural PASS');
// Find the index: cwd, then walk up a few levels.
let root = cwd, found = null;
for (let i = 0; i < 6 && root; i++) {
if (existsSync(join(root, '.codegraph'))) { found = root; break; }
const parent = resolve(root, '..'); if (parent === root) break; root = parent;
}
if (!found) { dbg(`no .codegraph found from cwd=${cwd}`); process.exit(0); }
dbg(`found index at ${found}`);
try {
process.env.CODEGRAPH_OFFLOAD_DISABLE = '1'; // raw, accurate — never the unfixed offload
process.env.CODEGRAPH_TELEMETRY = '0'; process.env.DO_NOT_TRACK = '1';
const load = async (rel) => import(pathToFileURL(resolve(ENGINE, rel)).href);
const idx = await load('dist/index.js');
const tools = await load('dist/mcp/tools.js');
const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph;
const ToolHandler = tools.ToolHandler ?? tools.default?.ToolHandler;
if (typeof CodeGraph?.openSync !== 'function' || typeof ToolHandler !== 'function') process.exit(0);
// Retry once on a transient busy/locked index (the hook's openSync can race a
// freshly-warming daemon on the first prompt of a session).
let text = '';
for (let attempt = 1; attempt <= 2; attempt++) {
try {
const cg = CodeGraph.openSync(found);
const h = new ToolHandler(cg);
const res = await h.execute('codegraph_explore', { query: prompt });
text = res?.content?.[0]?.text ?? '';
try { cg.close?.(); } catch { /* ignore */ }
dbg(`explore attempt ${attempt} returned ${text.length} chars`);
break;
} catch (e) {
dbg(`explore attempt ${attempt} failed: ${e?.message || e}`);
if (attempt === 2) throw e;
await new Promise((r) => setTimeout(r, 800));
}
}
if (!text.trim()) { dbg('empty explore result, no-op'); process.exit(0); }
if (text.length > BUDGET) text = text.slice(0, BUDGET) + '\n…[front-load truncated to budget]';
process.stdout.write(
`## CodeGraph structural context (auto-retrieved for this question)\n` +
`The code graph was queried for your question; the relevant symbols, source, and call flow are below. ` +
`Treat the quoted source as already read. If you need more, call codegraph_explore with specific symbol names rather than grepping or reading files.\n\n` +
text + '\n'
);
dbg(`INJECTED ${text.length} chars`);
} catch (e) { dbg('ERROR: ' + (e?.stack || e?.message || e)); process.exit(0); } // degradable
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env node
// Accuracy judge. For each run in results.jsonl:
// - end-to-end: agent finalAnswer vs verified ground truth (all arms)
// - fidelity: offload synthesized answer vs ground truth (offload arm only)
// Judge = claude -p sonnet --effort high, no tools, run from a neutral cwd,
// JSON-only verdicts. Writes judged.jsonl (one line per run, verdicts merged).
//
// Usage: judge.mjs --results <f> --truth <f> --out <f> [--concurrency 4]
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { execFile } from 'child_process';
const A = {};
for (let i = 2; i < process.argv.length; i += 2) A[process.argv[i].replace(/^--/, '')] = process.argv[i + 1];
const results = readFileSync(A.results, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
const truth = JSON.parse(readFileSync(A.truth, 'utf8'));
const OUT = A.out || '/tmp/cg-offload-eval/judged.jsonl';
const CONC = Number(A.concurrency || 4);
function askJudge(prompt) {
return new Promise((resolve) => {
execFile('claude', ['-p', prompt, '--model', 'sonnet', '--effort', 'high',
'--max-budget-usd', '0.5', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}'],
// Run from a neutral dir with no repo files so the judge can't "cheat" by reading source.
{ cwd: process.env.AGENT_EVAL_OUT || '/tmp', maxBuffer: 1 << 24, timeout: 120000 },
(err, stdout) => {
const raw = (stdout || '').trim();
const m = raw.match(/\{[\s\S]*\}/);
if (!m) return resolve({ verdict: 'error', score: null, note: (err ? 'exec ' + err.message : 'no json').slice(0, 80) });
try { resolve(JSON.parse(m[0])); } catch { resolve({ verdict: 'error', score: null, note: 'parse fail' }); }
});
});
}
const e2ePrompt = (gt, ans) => `You are scoring whether an AI coding agent correctly answered a code-flow question about a repository. Judge ONLY against the verified ground truth. Do NOT use any tools.
QUESTION: ${gt.question}
VERIFIED GROUND TRUTH (the actual call path + files):
${gt.truth}
AGENT'S ANSWER:
${ans || '(empty)'}
Score how correct the agent's answer is vs the ground truth. A "pass" means it identifies the core mechanism and the major hops with the right files/symbols and makes no materially wrong claim. "partial" = right area but misses major hops or has notable errors. "fail" = wrong layer, fabricated, or misses the mechanism.
Output ONLY minified JSON, no prose, no code fences:
{"verdict":"pass|partial|fail","score":<0-100>,"missedHops":["..."],"wrongClaims":["..."],"note":"<=20 words"}`;
const fidPrompt = (gt, ans) => `You are scoring the FIDELITY of a machine-synthesized code-exploration answer against verified ground truth. The synthesized answer claims to trace a flow and cite file:line locations. Do NOT use any tools.
QUESTION: ${gt.question}
VERIFIED GROUND TRUTH (the actual call path + files):
${gt.truth}
SYNTHESIZED ANSWER (to score):
${ans || '(empty)'}
Judge: (1) is the traced call path correct vs ground truth? (2) are the cited files/symbols correct (not fabricated)? (3) if it gave a "Coverage:" verdict, was that verdict honest about what it actually covered? A confident WRONG trace is the worst outcome — penalize it harder than an honest "partial/not found".
Output ONLY minified JSON, no prose, no code fences:
{"verdict":"pass|partial|fail","score":<0-100>,"fabrication":<true|false>,"coverageHonest":<true|false>,"missedHops":["..."],"note":"<=20 words"}`;
// Build the job list
const jobs = [];
for (const r of results) {
const gt = truth[r.repo];
if (!gt) { r._nojudge = true; continue; }
jobs.push({ r, kind: 'e2e', prompt: e2ePrompt(gt, r.finalAnswer) });
if (r.arm === 'offload' && Array.isArray(r.offloadAnswers))
r.offloadAnswers.forEach((ans, i) => { if (ans && ans.trim()) jobs.push({ r, kind: 'fid', idx: i, prompt: fidPrompt(gt, ans) }); });
}
console.error(`judging ${jobs.length} verdicts across ${results.length} runs (concurrency ${CONC})...`);
let done = 0;
async function worker(queue) {
while (queue.length) {
const job = queue.shift();
const v = await askJudge(job.prompt);
if (job.kind === 'e2e') job.r.e2e = v; else (job.r._fid ??= []).push(v);
console.error(` [${++done}/${jobs.length}] ${job.r.repo}/${job.r.arm}#${job.r.rep} ${job.kind}: ${v.verdict}${v.score != null ? ' ' + v.score : ''}`);
}
}
const q = [...jobs];
await Promise.all(Array.from({ length: CONC }, () => worker(q)));
// Aggregate per-answer fidelity verdicts into one fidelity object per offload run.
const medOf = (a) => { a = [...a].sort((x, y) => x - y); return a.length ? (a.length % 2 ? a[(a.length - 1) / 2] : (a[a.length / 2 - 1] + a[a.length / 2]) / 2) : null; };
for (const r of results) {
if (r._fid?.length) {
const scores = r._fid.map(v => v.score).filter(x => x != null);
r.fidelity = {
n: r._fid.length, scores,
max: scores.length ? Math.max(...scores) : null,
min: scores.length ? Math.min(...scores) : null,
median: medOf(scores),
anyFabrication: r._fid.some(v => v.fabrication === true),
allCoverageHonest: r._fid.every(v => v.coverageHonest !== false),
verdicts: r._fid.map(v => v.verdict),
};
}
delete r._fid;
}
writeFileSync(OUT, results.map(r => JSON.stringify(r)).join('\n') + '\n');
console.error(`wrote ${OUT}`);
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Drive the 3-arm campaign (offload/raw/nocg) across all 4 tiers, n reps each, into one
# results.jsonl. Reads the canonical question per repo from offload-eval-ground-truth.json.
# Env: REPS (default 3) AGENT_EVAL_OUT=<scratch dir>
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
OUT="${AGENT_EVAL_OUT:-/tmp/cg-offload-eval}"
GT="$HERE/offload-eval-ground-truth.json"
REPS="${REPS:-3}"
export RESULTS="$OUT/results.jsonl"
: > "$RESULTS"
for repo in mtkruto postybirb shapeshift trezor; do
case "$repo" in mtkruto) tier=small;; postybirb) tier=medium;; shapeshift) tier=complex;; trezor) tier=large;; esac
Q=$(node -e "console.log(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))[process.argv[2]].question)" "$GT" "$repo")
echo ""; echo "### $repo ($tier) $(date +%H:%M:%S)"
bash "$HERE/offload-eval-3arm.sh" "$OUT/repos/$repo" "$tier" "$REPS" "$Q"
done
echo ""; echo "###### MATRIX DONE -> $RESULTS ($(wc -l < "$RESULTS") runs). Judge + summarize with:"
echo " node $HERE/offload-eval-judge.mjs --results $RESULTS --truth $GT --out $OUT/judged.jsonl"
echo " node $HERE/offload-eval-summarize.mjs $OUT/judged.jsonl"
@@ -0,0 +1,94 @@
#!/usr/bin/env node
// Extract one eval run's metrics from its Claude stream-json transcript + the
// offload usage sidecar log, emit ONE merged JSON line.
//
// Usage: extract-metrics.mjs --run <run.jsonl> --usage <usage.jsonl|-> \
// --arm <a> --rep <n> --repo <r> --tier <t> --q <question>
import { readFileSync, existsSync } from 'fs';
const args = {};
for (let i = 2; i < process.argv.length; i += 2) args[process.argv[i].replace(/^--/, '')] = process.argv[i + 1];
const runFile = args.run;
const lines = existsSync(runFile) ? readFileSync(runFile, 'utf8').split('\n').filter(Boolean) : [];
const toolCounts = {};
let result = null;
const tok = { gen: 0, fresh: 0, cached: 0 };
const offloadAnswers = [];
let exploreResults = 0; // tool_results from explore (offload or raw)
let lastAssistantText = '';
for (const line of lines) {
let ev; try { ev = JSON.parse(line); } catch { continue; }
// per-turn token usage (authoritative token measure; result.usage is last-turn only)
const u = ev.message?.usage;
if (u) {
tok.gen += u.output_tokens || 0;
tok.fresh += (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0);
tok.cached += u.cache_read_input_tokens || 0;
}
if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
for (const b of ev.message.content) {
if (b.type === 'tool_use') toolCounts[b.name] = (toolCounts[b.name] || 0) + 1;
if (b.type === 'text' && b.text?.trim()) lastAssistantText = b.text.trim();
}
}
// tool_results arrive in user messages
if (ev.type === 'user' && Array.isArray(ev.message?.content)) {
for (const b of ev.message.content) {
if (b.type !== 'tool_result') continue;
const text = Array.isArray(b.content)
? b.content.map(c => (typeof c === 'string' ? c : c.text || '')).join('')
: (typeof b.content === 'string' ? b.content : '');
// An offload answer is either the 'plain'/'report' synthesis (carries the
// "Synthesized by CodeGraph" footer) or a 'refs' answer (carries the re-expanded
// "### Referenced source — verbatim" appendix). A refs call that cited nothing
// valid falls back to RAW source, which is correctly counted as a raw explore below.
if (/Synthesized by CodeGraph|### Referenced source — verbatim/.test(text)) { offloadAnswers.push(text); exploreResults++; }
else if (/Found \d+ symbols? across|\*\*Exploration:/.test(text)) exploreResults++;
}
}
if (ev.type === 'result') result = ev;
}
// offload usage sidecar (CodeGraph AI tokens + cost) — one JSON line per offload call
const ai = { calls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, credits: 0, costUsd: 0, ms: 0 };
if (args.usage && args.usage !== '-' && existsSync(args.usage)) {
for (const line of readFileSync(args.usage, 'utf8').split('\n').filter(Boolean)) {
let e; try { e = JSON.parse(line); } catch { continue; }
ai.calls++;
ai.promptTokens += e.promptTokens || 0;
ai.completionTokens += e.completionTokens || 0;
ai.totalTokens += e.totalTokens || 0;
ai.credits += e.creditsCharged || 0;
ai.costUsd += e.costUsd || 0;
ai.ms += e.ms || 0;
}
}
// front-load hook fired iff its injected header appears in the transcript
const frontload = lines.some(l => l.includes('auto-retrieved for this question'));
const get = (n) => toolCounts[n] || 0;
const read = get('Read');
const grep = get('Grep') + get('Bash') + get('Glob');
const explore = get('mcp__codegraph__codegraph_explore');
const cgAny = Object.keys(toolCounts).filter(k => /mcp__codegraph__/.test(k)).reduce((s, k) => s + toolCounts[k], 0);
const out = {
repo: args.repo, tier: args.tier, arm: args.arm, rep: Number(args.rep), question: args.q,
ok: result?.subtype === 'success',
durationSec: result ? +(result.duration_ms / 1000).toFixed(1) : null,
numTurns: result?.num_turns ?? null,
costUsdMain: result ? +(result.total_cost_usd || 0).toFixed(4) : null,
tokGen: tok.gen, tokFresh: tok.fresh, tokCached: tok.cached, tokBillable: tok.gen + tok.fresh,
read, grep, explore, cgAny, frontload,
offloadFired: offloadAnswers.length,
ai,
// text payloads for the accuracy judge (kept separate; large)
finalAnswer: (result?.result || lastAssistantText || '').slice(0, 8000),
offloadAnswers: offloadAnswers.map(a => a.slice(0, 6000)),
};
process.stdout.write(JSON.stringify(out) + '\n');
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# ONE offload run on ONE indexed repo at a given offload STYLE (plain|refs), so we can
# watch a single agent transcript at a time (the user's one-run-at-a-time methodology).
# The OFFLOAD reasoning runs in the prewarmed DAEMON process, so the style env must be
# set on BOTH the daemon and the client MCP config. Writes one metrics line to RESULTS
# and leaves the raw stream-json at $RUNS/<repo>-<style>-<n>.jsonl for inspection.
#
# Usage: offload-eval-refs1.sh <indexed-repo> <style> <n> "<question>"
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"; ENGINE="$(cd "$HERE/../.." && pwd)"; BIN="$ENGINE/dist/bin/codegraph.js"
OUT="${AGENT_EVAL_OUT:-/tmp/cg-offload-eval}"; RUNS="$OUT/runs"; EXTRACT="$HERE/offload-eval-metrics.mjs"
TARGET="${1:?repo}"; STYLE="${2:?style}"; N="${3:?run-tag}"; Q="${4:?question}"
RESULTS="${RESULTS:-$OUT/results-refs.jsonl}"; REPO=$(basename "$TARGET"); TARGET=$(cd "$TARGET" && pwd -P)
mkdir -p "$RUNS"; command -v claude >/dev/null || { echo "no claude"; exit 1; }
USAGE="$RUNS/$REPO-$STYLE-usage.jsonl"; : > "$USAGE"
CFG="$RUNS/mcp-$REPO-$STYLE.json"
# `raw` is a pseudo-style: codegraph attached but the offload DISABLED (the ceiling —
# verbatim source, no reasoning model). Any other value is an offload style (plain|refs).
if [ "$STYLE" = "raw" ]; then
DAEMON_ENV="CODEGRAPH_OFFLOAD_DISABLE=1"
printf '{"mcpServers":{"codegraph":{"command":"env","args":["CODEGRAPH_WASM_RELAUNCHED=1","CODEGRAPH_OFFLOAD_DISABLE=1","node","%s","serve","--mcp","--path","%s"]}}}' \
"$BIN" "$TARGET" > "$CFG"
USAGE="-"
else
DAEMON_ENV="CODEGRAPH_OFFLOAD_STYLE=$STYLE CODEGRAPH_OFFLOAD_USAGE_LOG=$USAGE"
printf '{"mcpServers":{"codegraph":{"command":"env","args":["CODEGRAPH_WASM_RELAUNCHED=1","CODEGRAPH_OFFLOAD_STYLE=%s","CODEGRAPH_OFFLOAD_USAGE_LOG=%s","node","%s","serve","--mcp","--path","%s"]}}}' \
"$STYLE" "$USAGE" "$BIN" "$TARGET" > "$CFG"
fi
# Prewarm a persistent daemon carrying the SAME offload config (it does the reasoning).
pkill -9 -f "serve --mcp --path $TARGET" 2>/dev/null; rm -f "$TARGET/.codegraph/daemon.sock" 2>/dev/null; sleep 0.6
env $DAEMON_ENV CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS=1800000 \
node "$BIN" serve --mcp --path "$TARGET" </dev/null >/dev/null 2>&1 &
node -e 'const fs=require("fs");let n=0;const t=setInterval(()=>{if(fs.existsSync(process.argv[1]+"/.codegraph/daemon.sock")){clearInterval(t);process.exit(0)}if(n++>150){clearInterval(t);process.exit(1)}},100)' "$TARGET" \
&& echo "daemon warm ($STYLE)" || echo "WARN daemon never bound"
tag="$REPO-$STYLE-$N"
echo "== run $tag =="
# DISALLOW (optional): block tools that confound the offload-sufficiency signal —
# chiefly "Agent" (sub-agent delegation: the spawned Explore subagent has low MCP
# salience, ignores codegraph, and thrashes via Bash+Read, making the A/B noise).
( cd "$TARGET" && claude -p "$Q" --output-format stream-json --verbose --permission-mode bypassPermissions \
--model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 \
${DISALLOW:+--disallowedTools "$DISALLOW"} \
--strict-mcp-config --mcp-config "$CFG" </dev/null > "$RUNS/$tag.jsonl" 2>"$RUNS/$tag.err" )
node "$EXTRACT" --run "$RUNS/$tag.jsonl" --usage "$USAGE" --arm "offload-$STYLE" --rep "$N" \
--repo "$REPO" --tier "complex" --q "$Q" >> "$RESULTS"
node -e 'const o=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8").trim().split("\n").pop());console.log(` [${o.arm} #${o.rep}] ${o.durationSec}s | main $${o.costUsdMain} ${o.tokBillable} tok | read=${o.read} grep=${o.grep} explore=${o.explore} offload=${o.offloadFired} | AI ${o.ai.calls}call/${o.ai.totalTokens}tok/$${o.ai.costUsd.toFixed(4)} | ok=${o.ok}`)' "$RESULTS"
pkill -9 -f "serve --mcp --path $TARGET" 2>/dev/null; rm -f "$TARGET/.codegraph/daemon.sock" 2>/dev/null
echo "raw transcript: $RUNS/$tag.jsonl"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Clone + index the 4 "not-trained-on" eval repos into $AGENT_EVAL_OUT/repos. These were
# selected via a no-tools memory-probe gate (Sonnet cannot answer their flow questions from
# memory — so the no-codegraph baseline is honest). Env: AGENT_EVAL_OUT=<scratch dir>
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ENGINE="$(cd "$HERE/../.." && pwd)"
BIN="$ENGINE/dist/bin/codegraph.js"
OUT="${AGENT_EVAL_OUT:-/tmp/cg-offload-eval}"
ROOT="$OUT/repos"; mkdir -p "$ROOT"
export CODEGRAPH_TELEMETRY=0 DO_NOT_TRACK=1
[ -f "$BIN" ] || { echo "engine not built: run 'npm run build' in $ENGINE first"; exit 1; }
clone_index() { # url name
echo "=== $2: clone ==="; rm -rf "$ROOT/$2"
git clone --quiet --depth 1 "$1" "$ROOT/$2" || { echo " clone FAILED"; return 1; }
echo "=== $2: index ==="
node "$BIN" init "$ROOT/$2" 2>&1 | grep -iE 'indexed|nodes|edges|error' | tail -2
}
clone_index https://github.com/MTKruto/MTKruto.git mtkruto # small (~322 TS)
clone_index https://github.com/mvdicarlo/postybirb-plus.git postybirb # medium (~608 TS)
clone_index https://github.com/shapeshift/web.git shapeshift # complex (~3.2k TS, 35-pkg monorepo)
clone_index https://github.com/trezor/trezor-suite.git trezor # large (~8k TS monorepo)
echo "###### SETUP DONE -> $ROOT"
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Offload reasoning-OUTPUT-STYLE A/B — all codegraph-on, isolating the Worker's
# output shape's effect on main-session tokens / latency / accuracy:
# raw : CODEGRAPH_OFFLOAD_DISABLE=1 (verbatim explore source, the floor)
# refs : managed offload, default (Cerebras map re-expanded to verbatim, ~24K)
# map : managed offload, STYLE=map (compact reasoned map + file:line anchors, ~1-3K)
# src : managed offload, STYLE=src (map + cited line ranges only, ~1-5K)
# Delegation BLOCKED by default (DISALLOW=Agent) so we measure the offload payload's
# effect on the main Sonnet agent, not whether it spawns a Haiku Explore subagent.
#
# Usage: offload-eval-styles.sh <indexed-repo> <reps> "<question>"
# Env: RESULTS=<file> AGENT_EVAL_OUT=<dir> REP_START=1 DISALLOW=Agent MODEL/EFFORT
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ENGINE="$(cd "$HERE/../.." && pwd)"
BIN="$ENGINE/dist/bin/codegraph.js"
OUT="${AGENT_EVAL_OUT:-/tmp/cg-offload-eval}"
TARGET="${1:?usage: offload-eval-styles.sh <indexed-repo> <reps> \"<question>\"}"
REPS="${2:?reps}"; Q="${3:?question}"
RUNS="$OUT/runs"; EXTRACT="$HERE/offload-eval-metrics.mjs"
RESULTS="${RESULTS:-$OUT/results-styles.jsonl}"
REPO=$(basename "$TARGET")
DISALLOW="${DISALLOW-Agent}" # default: block delegation. `DISALLOW= ` to allow.
START="${REP_START:-1}"; END=$((START + REPS - 1))
mkdir -p "$RUNS"
command -v claude >/dev/null || { echo "no claude on PATH"; exit 1; }
[ -d "$TARGET/.codegraph" ] || { echo "not indexed: $TARGET"; exit 1; }
TARGET=$(cd "$TARGET" && pwd -P)
prewarm() { # path extra-env
pkill -9 -f "serve --mcp --path $1" 2>/dev/null; rm -f "$1/.codegraph/daemon.sock" 2>/dev/null; sleep 0.6
env ${2:-} CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS=1800000 node "$BIN" serve --mcp --path "$1" </dev/null >/dev/null 2>&1 &
node -e 'const fs=require("fs");let n=0;const t=setInterval(()=>{if(fs.existsSync(process.argv[1]+"/.codegraph/daemon.sock")){clearInterval(t);process.exit(0)}if(n++>150){clearInterval(t);process.exit(1)}},100)' "$1" \
&& echo " daemon warm" || echo " WARN daemon never bound"
}
kill_daemon() { pkill -9 -f "serve --mcp --path $TARGET" 2>/dev/null; rm -f "$TARGET/.codegraph/daemon.sock" 2>/dev/null; sleep 1; }
run() { # arm rep mcp-config usage-log-or-dash
local arm="$1" rep="$2" cfg="$3" usage="$4" tag="$REPO-$1-$2"
[ "$usage" != "-" ] && : > "$usage"
( cd "$TARGET" && claude -p "$Q" \
--output-format stream-json --verbose --permission-mode bypassPermissions \
--model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4 \
${DISALLOW:+--disallowedTools "$DISALLOW"} \
--strict-mcp-config --mcp-config "$cfg" \
</dev/null > "$RUNS/$tag.jsonl" 2>"$RUNS/$tag.err" )
node "$EXTRACT" --run "$RUNS/$tag.jsonl" --usage "$usage" --arm "$arm" --rep "$rep" \
--repo "$REPO" --tier styles --q "$Q" >> "$RESULTS"
node -e 'const o=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8").trim().split("\n").pop());console.log(` [${o.arm} #${o.rep}] ${o.durationSec}s | ${o.tokBillable} billable tok | read=${o.read} grep=${o.grep} explore=${o.explore} offload=${o.offloadFired} | AI ${o.ai.calls}c/${o.ai.totalTokens}t | ok=${o.ok}`)' "$RESULTS"
}
# MCP configs: env baked into the daemon-spawn command claude uses.
USAGE="$RUNS/$REPO-usage.jsonl"
mkcfg() { # file extra-env-pairs(JSON array entries, comma-led or empty)
printf '{"mcpServers":{"codegraph":{"command":"env","args":["CODEGRAPH_WASM_RELAUNCHED=1"%s,"node","%s","serve","--mcp","--path","%s"]}}}' "$1" "$BIN" "$TARGET"
}
CFG_RAW="$RUNS/mcp-sty-raw-$REPO.json"; mkcfg ',"CODEGRAPH_OFFLOAD_DISABLE=1"' > "$CFG_RAW"
CFG_REFS="$RUNS/mcp-sty-refs-$REPO.json"; mkcfg ",\"CODEGRAPH_OFFLOAD_USAGE_LOG=$USAGE\"" > "$CFG_REFS"
CFG_MAP="$RUNS/mcp-sty-map-$REPO.json"; mkcfg ",\"CODEGRAPH_OFFLOAD_USAGE_LOG=$USAGE\",\"CODEGRAPH_OFFLOAD_STYLE=map\"" > "$CFG_MAP"
CFG_SRC="$RUNS/mcp-sty-src-$REPO.json"; mkcfg ",\"CODEGRAPH_OFFLOAD_USAGE_LOG=$USAGE\",\"CODEGRAPH_OFFLOAD_STYLE=src\"" > "$CFG_SRC"
echo "###### repo=$REPO reps=$START..$END model=${MODEL:-sonnet}/${EFFORT:-high} disallow=${DISALLOW:-<none>}"
echo "###### Q=$Q"
echo "== ARM raw =="; prewarm "$TARGET" "CODEGRAPH_OFFLOAD_DISABLE=1"
for r in $(seq "$START" "$END"); do run raw "$r" "$CFG_RAW" "-"; done; kill_daemon
echo "== ARM refs =="; prewarm "$TARGET" "CODEGRAPH_OFFLOAD_USAGE_LOG=$USAGE"
for r in $(seq "$START" "$END"); do run refs "$r" "$CFG_REFS" "$USAGE"; done; kill_daemon
echo "== ARM map =="; prewarm "$TARGET" "CODEGRAPH_OFFLOAD_USAGE_LOG=$USAGE CODEGRAPH_OFFLOAD_STYLE=map"
for r in $(seq "$START" "$END"); do run map "$r" "$CFG_MAP" "$USAGE"; done; kill_daemon
echo "== ARM src =="; prewarm "$TARGET" "CODEGRAPH_OFFLOAD_USAGE_LOG=$USAGE CODEGRAPH_OFFLOAD_STYLE=src"
for r in $(seq "$START" "$END"); do run src "$r" "$CFG_SRC" "$USAGE"; done; kill_daemon
echo "###### DONE $REPO — judge: node $HERE/offload-eval-judge.mjs --results $RESULTS --truth $HERE/offload-eval-ground-truth.json --out $OUT/judged-styles.jsonl"
@@ -0,0 +1,68 @@
#!/usr/bin/env node
// Aggregate judged.jsonl (or results.jsonl) into a per-repo, per-arm report:
// time, main tokens/cost, AI tokens/cost, total cost, tool mix, accuracy.
// Usage: summarize.mjs <judged-or-results.jsonl>
import { readFileSync } from 'fs';
const rows = readFileSync(process.argv[2], 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
const med = (xs) => { const a = xs.filter(x => x != null).sort((p, q) => p - q); if (!a.length) return null; const m = Math.floor(a.length / 2); return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2; };
const rng = (xs) => { const a = xs.filter(x => x != null); return a.length ? `${Math.min(...a)}${Math.max(...a)}` : '—'; };
const d2 = (x) => x == null ? '—' : (+x).toFixed(2);
const d3 = (x) => x == null ? '—' : (+x).toFixed(3);
const d4 = (x) => x == null ? '—' : (+x).toFixed(4);
const ARM_ORDER = ['frontload', 'offload', 'raw', 'nocg'];
const byRepo = {};
for (const r of rows) (byRepo[r.repo] ??= {});
for (const r of rows) ((byRepo[r.repo][r.arm] ??= []).push(r));
const verdictTally = (rs, field) => {
const t = { pass: 0, partial: 0, fail: 0, error: 0 };
for (const r of rs) { const v = r[field]?.verdict; if (v in t) t[v]++; }
return t;
};
for (const repo of Object.keys(byRepo)) {
const tier = byRepo[repo][Object.keys(byRepo[repo])[0]][0].tier;
console.log(`\n${'='.repeat(78)}\n${repo} [${tier}]\n${'='.repeat(78)}`);
console.log(`${'arm'.padEnd(9)} n ${'time(s)'.padStart(9)} ${'mainCost'.padStart(9)} ${'aiCost'.padStart(8)} ${'totCost'.padStart(8)} ${'mainTok'.padStart(8)} ${'aiTok'.padStart(7)} ${'rd'.padStart(3)} ${'gr'.padStart(3)} ${'exp'.padStart(3)} ${'off'.padStart(3)} e2e(P/p/F) fidScore`);
for (const arm of ARM_ORDER) {
const rs = byRepo[repo][arm]; if (!rs) continue;
const n = rs.length;
const mainCost = med(rs.map(r => r.costUsdMain));
const aiCost = med(rs.map(r => r.ai?.costUsd ?? 0));
const totCost = (mainCost ?? 0) + (aiCost ?? 0);
const e2e = verdictTally(rs, 'e2e');
const fidScores = arm === 'offload' ? rs.flatMap(r => r.fidelity?.scores ?? []) : [];
const fid = fidScores.length ? med(fidScores) : null;
const fab = arm === 'offload' && rs.some(r => r.fidelity?.anyFabrication);
const e2eScore = med(rs.map(r => r.e2e?.score).filter(x => x != null));
console.log(
`${arm.padEnd(9)} ${String(n).padStart(1)} ${String(med(rs.map(r => r.durationSec))).padStart(9)} ` +
`${('$' + d3(mainCost)).padStart(9)} ${('$' + d3(aiCost)).padStart(8)} ${('$' + d3(totCost)).padStart(8)} ` +
`${String(Math.round(med(rs.map(r => r.tokBillable)) / 1000) + 'k').padStart(8)} ${String(Math.round(med(rs.map(r => r.ai?.totalTokens ?? 0)) / 1000) + 'k').padStart(7)} ` +
`${String(med(rs.map(r => r.read))).padStart(3)} ${String(med(rs.map(r => r.grep))).padStart(3)} ${String(med(rs.map(r => r.explore))).padStart(3)} ${String(med(rs.map(r => r.offloadFired))).padStart(3)} ` +
`${(e2e.pass + '/' + e2e.partial + '/' + e2e.fail).padStart(9)} ${e2eScore != null ? 'e2e=' + e2eScore : ''} ${fid != null ? 'fid=' + fid + (fab ? ' FAB!' : '') : ''}`
);
}
// ranges line for the two key metrics (variance matters)
for (const arm of ARM_ORDER) {
const rs = byRepo[repo][arm]; if (!rs) continue;
console.log(` ${arm} ranges: time ${rng(rs.map(r => r.durationSec))}s · mainCost $${rng(rs.map(r => r.costUsdMain))} · read ${rng(rs.map(r => r.read))} · explore ${rng(rs.map(r => r.explore))} · offloadFired ${rng(rs.map(r => r.offloadFired))}`);
}
}
// Cross-repo roll-up: offload vs raw vs nocg deltas
console.log(`\n${'='.repeat(78)}\nCROSS-REPO SUMMARY (medians per repo, then averaged)\n${'='.repeat(78)}`);
console.log(`${'repo'.padEnd(12)} ${'arm'.padEnd(8)} ${'time'.padStart(7)} ${'totCost'.padStart(8)} ${'read'.padStart(5)} ${'e2e pass%'.padStart(9)} ${'fid'.padStart(5)}`);
for (const repo of Object.keys(byRepo)) {
for (const arm of ARM_ORDER) {
const rs = byRepo[repo][arm]; if (!rs) continue;
const e2e = verdictTally(rs, 'e2e');
const passPct = Math.round(100 * e2e.pass / rs.length);
const totCost = (med(rs.map(r => r.costUsdMain)) ?? 0) + (med(rs.map(r => r.ai?.costUsd ?? 0)) ?? 0);
const fid = arm === 'offload' ? med(rs.flatMap(r => r.fidelity?.scores ?? [])) : null;
console.log(`${repo.padEnd(12)} ${arm.padEnd(8)} ${(med(rs.map(r => r.durationSec)) + 's').padStart(7)} ${('$' + d3(totCost)).padStart(8)} ${String(med(rs.map(r => r.read))).padStart(5)} ${(passPct + '%').padStart(9)} ${String(fid ?? '—').padStart(5)}`);
}
}
console.log('');
+76
View File
@@ -0,0 +1,76 @@
# CodeGraph AI offload — accuracy & adoption eval harness
Measures the managed **offload** (`codegraph_explore` → reasoning model synthesis) and the
**front-load hook** (approach 1) against plain codegraph and no-codegraph, across repo sizes,
on **time · main-session tokens/cost · CodeGraph-AI tokens/cost · accuracy**.
All agent arms run `claude -p --model sonnet --effort high` (the deliberate floor model — an
affordance that lands on Sonnet generalizes up). Everything writes to a scratch dir
(`AGENT_EVAL_OUT`, default `/tmp/cg-offload-eval`); nothing here is shipped to users.
## Repos (selected via a memory-probe gate — NOT trained on)
Famous repos (express, excalidraw, n8n, …) are useless for *accuracy* evals: Sonnet answers their
flow questions from memory, so the no-codegraph baseline is dishonest. These four passed a no-tools
probe (Sonnet could not name their real flow internals) and are cloned fresh by `offload-eval-setup.sh`:
| tier | repo | ~src files | canonical flow |
|---|---|---|---|
| small | MTKruto/MTKruto | 322 TS | `sendMessage` → invoke → TL serialize → transport |
| medium | mvdicarlo/postybirb-plus | 608 TS | submission → queue → per-website `.post()` |
| complex | shapeshift/web | 3.2k TS (35-pkg monorepo) | swap → swapper registry → concrete swapper |
| large | trezor/trezor-suite | 8k TS monorepo | send-form → sign thunk → `@trezor/connect` |
Verified ground-truth flows (the judge's reference) live in `offload-eval-ground-truth.json`.
## Arms
- **offload** — codegraph + managed offload ON (requires `codegraph login`); records AI tokens/credits via `CODEGRAPH_OFFLOAD_USAGE_LOG`.
- **raw** — codegraph, `CODEGRAPH_OFFLOAD_DISABLE=1` (returns raw source).
- **nocg** — empty MCP config; Read/Grep baseline.
- **frontload** — codegraph (offload-disabled) + a `UserPromptSubmit` hook (`offload-eval-hook.mjs`) that runs raw explore on the prompt and injects the result into context (approach 1).
## Run it
```bash
npm run build # the harness shells out to dist/
codegraph login # only needed for the offload arm
export AGENT_EVAL_OUT=/tmp/cg-offload-eval
bash scripts/agent-eval/offload-eval-setup.sh # clone + index the 4 repos
bash scripts/agent-eval/offload-eval-matrix.sh # 3 arms × 4 tiers × REPS (default 3)
node scripts/agent-eval/offload-eval-judge.mjs \
--results $AGENT_EVAL_OUT/results.jsonl \
--truth scripts/agent-eval/offload-eval-ground-truth.json \
--out $AGENT_EVAL_OUT/judged.jsonl
node scripts/agent-eval/offload-eval-summarize.mjs $AGENT_EVAL_OUT/judged.jsonl
bash scripts/agent-eval/offload-eval-frontload-matrix.sh # frontload arm + judge + merged summary
```
Single repo: `offload-eval-3arm.sh <indexed-repo> <tier> <reps> "<question>"` (or `-frontload.sh`).
## Files
- `offload-eval-setup.sh` — clone + index the 4 repos.
- `offload-eval-3arm.sh` / `-frontload.sh` — one repo, the arms.
- `offload-eval-matrix.sh` / `-frontload-matrix.sh` — drive all 4 tiers.
- `offload-eval-hook.mjs` — the front-load `UserPromptSubmit` hook (resolves its own engine; `CG_FRONTLOAD_DEBUG=<path>` to log injections; `CG_FRONTLOAD_BUDGET` to cap injected chars).
- `offload-eval-metrics.mjs` — one run's stream-json + usage log → one JSON metrics line.
- `offload-eval-judge.mjs` — Sonnet judge: end-to-end (agent final vs ground truth) + per-answer offload fidelity.
- `offload-eval-summarize.mjs` — per-tier, per-arm table + cross-repo roll-up.
- `offload-eval-ground-truth.json` — source-verified canonical flows.
## Findings (2026-06, n=3 — direction consistent, magnitudes noisy)
- **Raw codegraph is the efficiency win** — ~nocg accuracy, fewer reads, faster, no AI cost.
- **The offload is the least-accurate arm in all 4 tiers** — synthesized fidelity 1227/100 with
fabrication in 3/4 (e.g. invented website services; traced `ClientPlain`/`SessionPlain` instead of
the real encrypted path). Its speed/cost win is narrow (medium-only) and inversely correlated with
accuracy. **Use raw until offload fidelity is fixed.**
- **The front-load hook SOLVES adoption** — reads → 01 in every tier (incl. large, where the agent
otherwise read 1224 files); fired 12/12, 0 errors. Wins on medium/complex (100% pass). But it
**regresses small/large to partial** — it suppresses the reads that compensate for explore's gaps at
**dynamic boundaries** (async queues, redux thunks, facade/factory indirection).
- **Master lever for BOTH:** explore's dynamic-dispatch coverage. Fix it → front-load is complete
everywhere and the offload has the full flow to synthesize.
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env node
// Analyze the tool-surface ablation (/tmp/arms/<repo>/<arm>-r<n>.jsonl).
// Compares arms AE on trace adoption, Read/Grep fallback, codegraph payload,
// round-trips, and duration — averaged across runs per arm.
//
// The decisive signal is READS: if removing a tool raises Reads on a question
// class, that tool was load-bearing for it (not redundant). If removing it
// changes nothing, it was redundant.
//
// A control all tools no steering (baseline)
// B steer all tools trace-first (adoption)
// C no-explore hide explore trace-first (is explore redundant?)
// D trace-centric hide explore+context trace-first (is the survey pair redundant?)
// E control-probe hide explore+context trace-first (NON-flow Q — should degrade)
//
// Usage: node scripts/agent-eval/parse-arms.mjs [/tmp/arms]
import { readFileSync, readdirSync, existsSync, statSync } from 'fs';
import { join } from 'path';
const ROOT = process.argv[2] || '/tmp/arms';
const cgShort = (n) => n.replace('mcp__codegraph__codegraph_', '').replace('mcp__codegraph__', '');
function parse(file) {
if (!existsSync(file)) return null;
const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
const calls = []; let result = null, initCg = 0;
for (const l of lines) {
let ev; try { ev = JSON.parse(l); } catch { continue; }
if (ev.type === 'system' && ev.subtype === 'init') initCg = (ev.tools || []).filter(t => /codegraph/.test(t)).length;
if (ev.type === 'assistant') for (const b of (ev.message?.content || [])) if (b.type === 'tool_use')
calls.push({ id: b.id, name: b.name, out: 0 });
if (ev.type === 'user') for (const b of (ev.message?.content || [])) if (b.type === 'tool_result') {
const c = b.content;
const txt = typeof c === 'string' ? c : Array.isArray(c) ? c.map(x => x?.text || '').join('') : '';
const call = calls.find(k => k.id === b.tool_use_id); if (call) call.out = txt.length;
}
if (ev.type === 'result') result = ev;
}
const cg = calls.filter(c => c.name.includes('codegraph'));
return {
initCg,
reads: calls.filter(c => c.name === 'Read').length,
greps: calls.filter(c => c.name === 'Grep').length + calls.filter(c => c.name === 'Glob').length,
cgCalls: cg.length,
cgSeq: cg.map(c => cgShort(c.name)),
cgOut: cg.reduce((s, c) => s + c.out, 0),
traceUsed: cg.some(c => c.name.includes('trace')),
turns: result?.num_turns ?? null,
dur: result?.duration_ms ? Math.round(result.duration_ms / 1000) : null,
cost: result?.total_cost_usd || 0,
ok: result?.subtype === 'success',
};
}
// repo -> arm -> [runs]
const data = {};
if (!existsSync(ROOT)) { console.error(`no ${ROOT}`); process.exit(1); }
for (const repo of readdirSync(ROOT)) {
const rdir = join(ROOT, repo);
if (!statSync(rdir).isDirectory()) continue;
for (const f of readdirSync(rdir)) {
const m = f.match(/^([A-I])-r(\d+)\.jsonl$/); if (!m) continue;
const p = parse(join(rdir, f)); if (!p || !p.ok) continue;
(((data[repo] ??= {})[m[1]]) ??= []).push(p);
}
}
const avg = (a, f) => a.length ? a.reduce((s, x) => s + (f(x) || 0), 0) / a.length : 0;
const k = (n) => (n / 1000).toFixed(1);
const pad = (s, n) => String(s).padEnd(n);
const ARMS = ['A', 'H', 'I', 'B', 'F', 'G', 'C', 'D', 'E'];
const LABEL = { A: 'A all/none(old)', H: 'H body-trace/none', I: 'I bodytrace+dest', B: 'B all/steer(thin)', F: 'F all/steer(body)', G: 'G ported(noprompt)', C: 'C no-explore', D: 'D trace-centric', E: 'E nonflow-probe' };
// ---- per repo × arm ----
console.log('\n=== PER REPO × ARM (avg over runs) ===');
console.log(pad('repo', 22), pad('arm', 16), 'tools', 'trace', pad('reads', 6), pad('cgOutK', 7), pad('turns', 6), 'dur');
for (const repo of Object.keys(data).sort()) {
for (const arm of ARMS) {
const runs = data[repo][arm]; if (!runs?.length) continue;
console.log(
pad(repo, 22), pad(LABEL[arm], 16),
pad(runs[0].initCg, 5),
pad(runs.filter(r => r.traceUsed).length + '/' + runs.length, 5),
pad(avg(runs, r => r.reads).toFixed(1), 6),
pad(k(avg(runs, r => r.cgOut)), 7),
pad(avg(runs, r => r.turns).toFixed(1), 6),
avg(runs, r => r.dur).toFixed(0) + 's',
);
}
}
// ---- aggregate per arm (flow arms AD over the flow repos; E shown apart) ----
console.log('\n=== AGGREGATE PER ARM (mean across repos) ===');
console.log(pad('arm', 16), pad('adoption', 9), pad('reads', 7), pad('greps', 7), pad('cgOutK', 8), pad('turns', 7), pad('dur', 6), 'cost');
for (const arm of ARMS) {
const all = [];
for (const repo of Object.keys(data)) for (const r of (data[repo][arm] || [])) all.push({ ...r, repo });
if (!all.length) continue;
const repos = new Set(all.map(r => r.repo)).size;
const adopt = all.filter(r => r.traceUsed).length;
console.log(
pad(LABEL[arm], 16),
pad(`${adopt}/${all.length}`, 9),
pad(avg(all, r => r.reads).toFixed(2), 7),
pad(avg(all, r => r.greps).toFixed(2), 7),
pad(k(avg(all, r => r.cgOut)), 8),
pad(avg(all, r => r.turns).toFixed(1), 7),
pad(avg(all, r => r.dur).toFixed(0) + 's', 6),
'$' + avg(all, r => r.cost).toFixed(3),
` (${repos} repos)`,
);
}
console.log('\nRead the signal: B vs A = does steering alone fix adoption + cut payload.');
console.log('C vs B = is explore redundant (reads should NOT jump). D vs C = is context redundant.');
console.log('E = non-flow under trace-centric; reads SHOULD jump (proves survey tools are load-bearing).');
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env node
// Aggregate the README A/B (bench-readme.sh output): per repo, median of N runs
// per arm → time, tool calls, tokens, cost, and % saved. Plus an average row.
//
// Tokens = SUM of per-turn assistant `usage` (input + output + cache read +
// cache creation) — the cumulative "total tokens processed". NOTE: `result.usage`
// is last-turn-only in current Claude Code, so it under-counts badly; don't use it.
// `total_cost_usd` and `duration_ms` are already cumulative.
//
// Usage: node parse-bench-readme.mjs [/tmp/ab-readme]
import { readFileSync, existsSync, readdirSync } from 'fs';
import { join } from 'path';
const ROOT = process.argv[2] || '/tmp/ab-readme';
const REPOS = ['vscode', 'excalidraw', 'django', 'tokio', 'okhttp', 'gin', 'alamofire'];
function parse(file) {
if (!existsSync(file)) return null;
const L = readFileSync(file, 'utf8').split('\n').filter(Boolean);
let tools = 0, reads = 0, grep = 0, cg = 0, tokens = 0, r = null, raced = false;
for (const l of L) { let e; try { e = JSON.parse(l); } catch { continue; }
if (e.type === 'assistant') {
const u = e.message?.usage;
if (u) tokens += (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
for (const b of (e.message?.content || [])) if (b.type === 'tool_use') {
const n = b.name;
if (n === 'ToolSearch') continue;
tools++;
if (n === 'Read') reads++;
else if (n === 'Grep' || n === 'Glob') grep++;
else if (/codegraph/.test(n)) cg++;
}
}
// MCP cold-start race: the headless agent fired before `codegraph serve --mcp`
// finished registering its tools, so early calls returned "No such tool
// available" and the agent floundered into grep/Read. That measures CodeGraph's
// startup latency, NOT its steady-state value — flag the run so the aggregate
// can exclude it (an artifact of headless first-turn timing, not the tool).
if (e.type === 'user') for (const b of (Array.isArray(e.message?.content) ? e.message.content : [])) {
if (b.type === 'tool_result') {
const t = Array.isArray(b.content) ? b.content.map(c => c.text || '').join('') : (b.content || '');
if (/No such tool available/.test(t)) raced = true;
}
}
if (e.type === 'result') r = e;
}
if (!r || r.subtype !== 'success') return null;
return { dur: r.duration_ms / 1000, tools, reads, grep, cg, tokens, cost: r.total_cost_usd || 0, raced };
}
const median = (arr) => { const v = [...arr].sort((a, b) => a - b); const n = v.length; return n === 0 ? 0 : n % 2 ? v[(n - 1) / 2] : (v[n / 2 - 1] + v[n / 2]) / 2; };
const fmtTime = (s) => s >= 60 ? `${Math.floor(s / 60)}m ${Math.round(s % 60)}s` : `${Math.round(s)}s`;
const fmtTok = (t) => t >= 1e6 ? `${(t / 1e6).toFixed(1)}M` : `${Math.round(t / 1000)}k`;
const pct = (w, wo) => wo > 0 ? Math.round((1 - w / wo) * 100) : 0;
console.log('repo n(w/wo) time WITH→WITHOUT tools W→WO tokens W→WO (saved) cost W→WO (saved)');
const savings = { cost: [], tokens: [], time: [], tools: [] };
for (const repo of REPOS) {
const dir = join(ROOT, repo);
const runDirs = existsSync(dir) ? readdirSync(dir).filter(d => /^run\d+$/.test(d)) : [];
// Exclude MCP-cold-start-raced WITH runs by default — they measure a startup
// race, not steady-state value. `CG_INCLUDE_RACED=1` keeps them (to see the raw
// distribution). The WITHOUT arm has no MCP, so it's never raced.
const includeRaced = process.env.CG_INCLUDE_RACED === '1';
const W = [], WO = []; let racedExcluded = 0;
for (const rd of runDirs) {
const w = parse(join(dir, rd, 'run-headless-with.jsonl'));
if (w) { if (w.raced && !includeRaced) racedExcluded++; else W.push(w); }
const wo = parse(join(dir, rd, 'run-headless-without.jsonl')); if (wo) WO.push(wo);
}
if (!W.length || !WO.length) { console.log(`${repo.padEnd(11)} (incomplete: w=${W.length} wo=${WO.length})`); continue; }
const m = (arr, k) => median(arr.map(x => x[k]));
const wT = m(W, 'dur'), woT = m(WO, 'dur'), wTok = m(W, 'tokens'), woTok = m(WO, 'tokens');
const wC = m(W, 'cost'), woC = m(WO, 'cost'), wTl = m(W, 'tools'), woTl = m(WO, 'tools');
savings.time.push(pct(wT, woT)); savings.tokens.push(pct(wTok, woTok)); savings.cost.push(pct(wC, woC)); savings.tools.push(pct(wTl, woTl));
console.log(
`${repo.padEnd(11)} ${W.length}/${WO.length} ` +
`${(fmtTime(wT) + '→' + fmtTime(woT)).padEnd(22)}` +
`${(Math.round(wTl) + '→' + Math.round(woTl)).padEnd(12)}` +
`${(fmtTok(wTok) + '→' + fmtTok(woTok) + ' (' + pct(wTok, woTok) + '%)').padEnd(24)}` +
`$${wC.toFixed(2)}$${woC.toFixed(2)} (${pct(wC, woC)}%)` +
(racedExcluded ? ` [${racedExcluded} raced run${racedExcluded === 1 ? '' : 's'} excluded]` : '')
);
}
const avg = (a) => a.length ? Math.round(a.reduce((s, x) => s + x, 0) / a.length) : 0;
console.log(`\nAVERAGE saved: cost ${avg(savings.cost)}% · tokens ${avg(savings.tokens)}% · time ${avg(savings.time)}% · tool calls ${avg(savings.tools)}%`);
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
// Parse a Claude Code stream-json run log: tool-call sequence + token usage.
import { readFileSync } from 'fs';
const file = process.argv[2];
const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
const toolCalls = [];
let result = null;
let initTools = null;
for (const line of lines) {
let ev;
try { ev = JSON.parse(line); } catch { continue; }
if (ev.type === 'system' && ev.subtype === 'init') {
initTools = (ev.tools || []).filter(t => /codegraph/.test(t));
}
if (ev.type === 'assistant' && ev.message?.content) {
for (const block of ev.message.content) {
if (block.type === 'tool_use') {
let detail = '';
if (block.name === 'Task') detail = ` [subagent_type=${block.input?.subagent_type ?? '?'}] ${(block.input?.description ?? '').slice(0,40)}`;
else if (/codegraph/.test(block.name)) detail = ` ${JSON.stringify(block.input?.query ?? block.input?.task ?? block.input?.symbol ?? '').slice(0,60)}`;
else if (block.name === 'Bash') detail = ` ${(block.input?.command ?? '').slice(0,50)}`;
else if (block.name === 'Read') detail = ` ${(block.input?.file_path ?? '').split('/').slice(-1)[0]}`;
toolCalls.push(`${block.name}${detail}`);
}
}
}
if (ev.type === 'result') result = ev;
}
console.log(`\n=== ${file.split('/').pop()} ===`);
console.log(`codegraph tools exposed: ${initTools ? initTools.length : '?'}`);
console.log(`\nTool calls (${toolCalls.length}):`);
const counts = {};
for (const tc of toolCalls) { const n = tc.split(' ')[0]; counts[n] = (counts[n]||0)+1; }
console.log(' by type:', JSON.stringify(counts));
toolCalls.forEach((tc, i) => console.log(` ${i+1}. ${tc}`));
if (result) {
const u = result.usage || {};
const totalIn = (u.input_tokens||0) + (u.cache_read_input_tokens||0) + (u.cache_creation_input_tokens||0);
console.log(`\nResult: ${result.subtype} | duration ${(result.duration_ms/1000).toFixed(0)}s | turns ${result.num_turns}`);
console.log(` tokens: in=${totalIn} out=${u.output_tokens||0} | cost $${(result.total_cost_usd||0).toFixed(3)}`);
}
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env node
// Parse the newest Claude Code session log for a project + its subagent logs,
// and report the tool-call breakdown (main + subagents). Works for interactive
// runs (driven via itrun.sh) — Claude Code writes full transcripts to
// ~/.claude/projects/<escaped-cwd>/<session>.jsonl with subagents/ alongside.
import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
const projectArg = process.argv[2];
if (!projectArg) { console.error('usage: parse-session.mjs <project-dir>'); process.exit(1); }
// Claude Code escapes the (real) cwd by replacing every "/" with "-".
const real = realpathSync(projectArg);
const escaped = real.replace(/\//g, '-');
const projDir = join(homedir(), '.claude', 'projects', escaped);
if (!existsSync(projDir)) { console.error('no session logs at', projDir); process.exit(1); }
// Newest top-level session .jsonl
const sessions = readdirSync(projDir)
.filter(f => f.endsWith('.jsonl'))
.map(f => ({ f, m: statSync(join(projDir, f)).mtimeMs }))
.sort((a, b) => b.m - a.m);
if (sessions.length === 0) { console.error('no .jsonl sessions in', projDir); process.exit(1); }
const sessionId = sessions[0].f.replace('.jsonl', '');
function tally(file) {
const counts = {};
for (const line of readFileSync(file, 'utf8').split('\n')) {
if (!line) continue;
let ev; try { ev = JSON.parse(line); } catch { continue; }
const content = ev.message?.content;
if (!Array.isArray(content)) continue;
for (const b of content) {
if (b.type === 'tool_use') counts[b.name] = (counts[b.name] || 0) + 1;
}
}
return counts;
}
// Sum token usage from a transcript. The TUI's "Done (…Xk tokens…)" line only
// covers a subagent's throughput; this works for main-thread runs too and is
// consistent across both paths. `gen` = output, `fresh` = uncached input
// (input + cache_creation), `cached` = cache reads (≈free), `total` = all.
function sumTokens(file) {
const t = { gen: 0, fresh: 0, cached: 0 };
for (const line of readFileSync(file, 'utf8').split('\n')) {
if (!line) continue;
let ev; try { ev = JSON.parse(line); } catch { continue; }
const u = ev.message?.usage;
if (!u) continue;
t.gen += u.output_tokens || 0;
t.fresh += (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0);
t.cached += u.cache_read_input_tokens || 0;
}
return t;
}
const mainCounts = tally(join(projDir, sessionId + '.jsonl'));
// Subagent transcripts live under <session>/subagents/*.jsonl
const subDir = join(projDir, sessionId, 'subagents');
const subCounts = {};
let subAgentFiles = 0;
if (existsSync(subDir)) {
for (const f of readdirSync(subDir).filter(f => f.endsWith('.jsonl'))) {
subAgentFiles++;
const c = tally(join(subDir, f));
for (const [k, v] of Object.entries(c)) subCounts[k] = (subCounts[k] || 0) + v;
}
}
const fmt = (counts) => Object.entries(counts).sort((a, b) => b[1] - a[1])
.map(([k, v]) => ` ${String(v).padStart(3)} ${k}`).join('\n') || ' (none)';
console.log(`session: ${sessionId}`);
console.log(`\nMAIN thread tools:\n${fmt(mainCounts)}`);
console.log(`\nSUBAGENT tools (${subAgentFiles} subagent transcript${subAgentFiles === 1 ? '' : 's'}):\n${fmt(subCounts)}`);
const explore = subCounts['mcp__codegraph__codegraph_explore'] || mainCounts['mcp__codegraph__codegraph_explore'] || 0;
const reads = (subCounts['Read'] || 0) + (mainCounts['Read'] || 0);
const greps = (subCounts['Grep'] || 0) + (mainCounts['Grep'] || 0) + (subCounts['Bash'] || 0) + (mainCounts['Bash'] || 0);
console.log(`\nVERDICT: codegraph_explore used ${explore}x | Read ${reads} | Grep/Bash ${greps}`);
// Token totals (main + subagents), consistent across main-thread and subagent runs.
const tok = { gen: 0, fresh: 0, cached: 0 };
const addTok = (t) => { tok.gen += t.gen; tok.fresh += t.fresh; tok.cached += t.cached; };
addTok(sumTokens(join(projDir, sessionId + '.jsonl')));
if (existsSync(subDir)) {
for (const f of readdirSync(subDir).filter(f => f.endsWith('.jsonl'))) addTok(sumTokens(join(subDir, f)));
}
const k = (n) => (n / 1000).toFixed(1) + 'k';
console.log(`TOKENS: gen ${k(tok.gen)} | fresh-in ${k(tok.fresh)} | cached-in ${k(tok.cached)} | billable≈ ${k(tok.gen + tok.fresh)}`);
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
// Probe codegraph_context (with call-paths) against an index using the built dist.
// Usage: node probe-context.mjs <repo-with-.codegraph> <task words...>
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
const [, , repo, ...taskParts] = process.argv;
const task = taskParts.join(' ');
if (!repo || !task) { console.error('usage: probe-context.mjs <repo> <task...>'); process.exit(1); }
const load = async (rel) => import(pathToFileURL(resolve(rel)).href);
const idx = await load('dist/index.js');
const tools = await load('dist/mcp/tools.js');
const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph;
const ToolHandler = tools.ToolHandler ?? tools.default?.ToolHandler;
const cg = CodeGraph.openSync(repo);
const h = new ToolHandler(cg);
const res = await h.execute('codegraph_context', { task });
console.log(res.content?.[0]?.text ?? '(no text)');
try { cg.close?.(); } catch {}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env node
// One-shot probe: run handleExplore against an existing index using the built
// dist, print the output + a few stats. Lets us verify explore's coverage fix
// without a full agent run. Usage: node probe-explore.mjs <repo-with-.codegraph> "<query>"
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
const [, , repo, query] = process.argv;
if (!repo || !query) {
console.error('usage: probe-explore.mjs <repo> "<query>"');
process.exit(1);
}
const load = async (rel) => import(pathToFileURL(resolve(rel)).href);
const idx = await load('dist/index.js');
const tools = await load('dist/mcp/tools.js');
// esModuleInterop: dynamic import of CJS yields { default: module.exports, ...named }
const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph;
const ToolHandler = tools.ToolHandler ?? tools.default?.ToolHandler;
if (typeof CodeGraph?.openSync !== 'function') {
console.error('could not resolve CodeGraph.openSync; index keys:', Object.keys(idx), 'default keys:', idx.default && Object.keys(idx.default));
process.exit(2);
}
if (typeof ToolHandler !== 'function') {
console.error('could not resolve ToolHandler; tools keys:', Object.keys(tools));
process.exit(2);
}
const cg = CodeGraph.openSync(repo);
const h = new ToolHandler(cg);
const res = await h.execute('codegraph_explore', { query });
const text = res.content?.[0]?.text ?? '(no text)';
console.log(text);
console.error('\n--- PROBE STATS ---');
console.error('output chars:', text.length);
console.error('triggerRender body present (-> setState({})):', /triggerRender[\s\S]{0,400}setState\(\{\}\)/.test(text));
console.error('App.tsx in source section:', /\*\*`.*App\.tsx`\*\* —/.test(text));
try { cg.close?.(); } catch {}
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
// Probe codegraph_node (with trail) against an index using the built dist.
// Usage: node probe-node.mjs <repo-with-.codegraph> <symbol> [code]
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
const [, , repo, symbol, code] = process.argv;
if (!repo || !symbol) { console.error('usage: probe-node.mjs <repo> <symbol> [code]'); process.exit(1); }
const load = async (rel) => import(pathToFileURL(resolve(rel)).href);
const idx = await load('dist/index.js');
const tools = await load('dist/mcp/tools.js');
const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph;
const ToolHandler = tools.ToolHandler ?? tools.default?.ToolHandler;
const cg = CodeGraph.openSync(repo);
const h = new ToolHandler(cg);
const res = await h.execute('codegraph_node', { symbol, includeCode: code === 'code' });
console.log(res.content?.[0]?.text ?? '(no text)');
try { cg.close?.(); } catch {}
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
// probe-sweep — direct MCP test across N repos × N tools, no claude needed.
//
// Measures response characteristics (size, sections present, signals fired)
// for each (repo, query) pair against the built dist/. Sub-second per probe;
// the full sweep below runs in ~10-30s vs hours for a real claude audit.
//
// Use this to iterate on backend changes rapidly: change tools.ts /
// context-builder, npm run build, re-run probe-sweep, compare. Once a
// change looks good on probe metrics, run a focused claude audit for the
// few repos that matter to confirm end-to-end cost behavior.
//
// Usage: node scripts/agent-eval/probe-sweep.mjs [--tool=context|explore|trace] [--repos=a,b,c]
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
const args = Object.fromEntries(
process.argv.slice(2).map(a => a.startsWith('--') ? a.slice(2).split('=') : [a, true])
);
const TOOL = args.tool ?? 'context';
const load = (rel) => import(pathToFileURL(resolve(rel)).href);
const idx = await load('dist/index.js');
const tools = await load('dist/mcp/tools.js');
const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph;
const ToolHandler = tools.ToolHandler ?? tools.default?.ToolHandler;
// Each entry: repo, query, optional 2nd arg for trace (from, to).
// The query is the same prompt used in the real claude audits, so probe
// output is directly comparable to the agent's would-be input.
const SWEEP = [
// Small realworld template repos (the loss cases from the cross-language sweep)
{ id: 'gin-rw', repo: '/tmp/codegraph-corpus/gin-realworld', q: 'How does this Gin app route a request through its middleware chain to a handler?' },
{ id: 'go-mux', repo: '/tmp/codegraph-corpus/go-mux', q: 'How does this gorilla/mux app route a request to its handler?' },
{ id: 'fastapi-rw', repo: '/tmp/codegraph-corpus/fastapi-realworld', q: 'How does FastAPI route a request through its dependencies to a handler?' },
{ id: 'spring-pc', repo: '/tmp/codegraph-corpus/spring-petclinic', q: 'How does Spring route an HTTP request to a controller method?' },
{ id: 'axum-rw', repo: '/tmp/codegraph-corpus/rust-axum-realworld', q: 'How does Axum route a request to its handler in this app?' },
{ id: 'express-rw', repo: '/tmp/codegraph-corpus/express-realworld', q: 'How does this Express app route a request through middleware to a handler?' },
{ id: 'kotlin-pc', repo: '/tmp/codegraph-corpus/kotlin-petclinic', q: 'How does the Kotlin Spring app route an HTTP request to its handler?' },
{ id: 'flask-mb', repo: '/tmp/codegraph-corpus/flask-microblog', q: 'How does this Flask app route a request to a view function?' },
{ id: 'vapor-tpl', repo: '/tmp/codegraph-corpus/vapor-template', q: 'How does Vapor route an HTTP request to its handler?' },
{ id: 'cpp-leveldb', repo: '/tmp/codegraph-corpus/cpp-leveldb', q: 'How does LevelDB handle a Put operation through to disk?' },
{ id: 'lualine', repo: '/tmp/codegraph-corpus/lualine.nvim', q: 'How does lualine assemble and render the statusline?' },
{ id: 'drupal-admin', repo: '/tmp/codegraph-corpus/drupal-admintoolbar', q: 'How does the Drupal admin toolbar module render its toolbar?' },
{ id: 'svelte-rw', repo: '/tmp/codegraph-corpus/svelte-realworld', q: 'How does this SvelteKit app route a request to a handler?' },
{ id: 'react-rw', repo: '/tmp/codegraph-corpus/react-realworld', q: 'How does this React app fetch and display articles?' },
{ id: 'rails-rw', repo: '/tmp/codegraph-corpus/rails-realworld', q: 'How does Rails route a request to a controller action?' },
{ id: 'flask-rest', repo: '/tmp/codegraph-corpus/flask-restful-realworld', q: 'How does Flask-RESTful route a request to a resource method?' },
{ id: 'laravel-rw', repo: '/tmp/codegraph-corpus/laravel-realworld', q: 'How does Laravel route a request to the controller method?' },
{ id: 'aspnet-rw', repo: '/tmp/codegraph-corpus/aspnet-realworld', q: 'How does ASP.NET route a request to the controller action?' },
// The iter7 wins/ties (to make sure we don't regress)
{ id: 'cobra', repo: '/tmp/codegraph-corpus/cobra', q: 'How does cobra parse commands and flags?' },
{ id: 'sinatra', repo: '/tmp/codegraph-corpus/sinatra', q: 'How does sinatra route a request to its handler?' },
{ id: 'slim', repo: '/tmp/codegraph-corpus/slim', q: 'How does slim route a request and apply middleware?' },
];
// Detect signals in response text — these are the levers we've added that
// otherwise only show up via "agent ran X more tool calls" downstream.
const detect = (text) => ({
hasEntryPoints: /^### Entry Points/m.test(text),
hasRelatedSymbols: /^### Related Symbols/m.test(text),
hasFlowTrace: /^## Inline flow trace/m.test(text),
hasRouteManifest: /^## Routing manifest/m.test(text),
hasTopHandler: /^### Top handler file/m.test(text),
hasSmallRepoTail: /This project is small/.test(text),
});
const filterRepos = args.repos ? new Set(String(args.repos).split(',')) : null;
const subjects = SWEEP.filter(s => !filterRepos || filterRepos.has(s.id));
const t0 = Date.now();
const rows = [];
for (const s of subjects) {
try {
const cg = CodeGraph.openSync(s.repo);
const handler = new ToolHandler(cg);
const t1 = Date.now();
const res = await handler.execute('codegraph_' + TOOL,
TOOL === 'context' ? { task: s.q } :
TOOL === 'explore' ? { query: s.q } : { from: 'main', to: 'main' });
const text = res.content?.[0]?.text ?? '';
const signals = detect(text);
rows.push({
id: s.id,
ms: Date.now() - t1,
chars: text.length,
lines: text.split('\n').length,
...signals,
});
try { cg.close?.(); } catch {}
} catch (e) {
rows.push({ id: s.id, error: String(e).slice(0, 80) });
}
}
// Pretty-print as a compact table.
const fmt = (r) =>
r.error
? ` ${r.id.padEnd(13)} ERROR: ${r.error}`
: ` ${r.id.padEnd(13)} ${String(r.chars).padStart(6)}c ${String(r.lines).padStart(4)}L ${String(r.ms).padStart(4)}ms` +
` ${r.hasEntryPoints ? 'EP ' : ' '}` +
`${r.hasFlowTrace ? 'TRC ' : ' '}` +
`${r.hasRouteManifest ? 'MAN ' : ' '}` +
`${r.hasTopHandler ? 'HND ' : ' '}` +
`${r.hasSmallRepoTail ? 'TAIL' : ' '}`;
console.log(`=== probe-sweep tool=${TOOL} n=${subjects.length} (${Date.now() - t0}ms total) ===`);
console.log(' id chars lines ms signals');
console.log(' ' + '-'.repeat(56));
for (const r of rows) console.log(fmt(r));
// Sum + medians for the size pillar
const sizes = rows.filter(r => !r.error).map(r => r.chars);
sizes.sort((a, b) => a - b);
const median = sizes[Math.floor(sizes.length / 2)];
const sum = sizes.reduce((a, b) => a + b, 0);
console.log(` ${'-'.repeat(64)}`);
console.log(` median=${median}c total=${sum}c ` +
`manifest=${rows.filter(r => r.hasRouteManifest).length}/${rows.filter(r => !r.error).length} ` +
`top-handler=${rows.filter(r => r.hasTopHandler).length}/${rows.filter(r => !r.error).length}`);
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
// Probe codegraph_trace against an index using the built dist.
// Usage: node probe-trace.mjs <repo-with-.codegraph> <from> <to>
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
const [, , repo, from, to] = process.argv;
if (!repo || !from || !to) { console.error('usage: probe-trace.mjs <repo> <from> <to>'); process.exit(1); }
const load = async (rel) => import(pathToFileURL(resolve(rel)).href);
const idx = await load('dist/index.js');
const tools = await load('dist/mcp/tools.js');
const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph;
const ToolHandler = tools.ToolHandler ?? tools.default?.ToolHandler;
const cg = CodeGraph.openSync(repo);
const h = new ToolHandler(cg);
const res = await h.execute('codegraph_trace', { from, to });
console.log(res.content?.[0]?.text ?? '(no text)');
try { cg.close?.(); } catch {}
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# PreToolUse(Read) REDIRECT hook — prototype for A/B (P1: get agents off Read and
# onto codegraph_node during implementation, not just for Q&A).
#
# When the agent Reads a SOURCE file, deny it and steer to codegraph_node's
# file-view, which (as of the Lever-1 change) returns the WHOLE file verbatim
# WITH line numbers — imports, top-level code, comments and all — PLUS the file's
# blast radius, in one call. That output is a strict superset of Read, so the
# redirect is lossless: the agent loses nothing by taking it, and gains who-
# depends-on-this for the edit it's about to make.
#
# Differs from block-read-hook.sh (which steers to explore/node-by-symbol): this
# names the FILE-VIEW path explicitly (file:"<base>" + includeCode:true), the
# 1:1 Read replacement we're trying to get picked during implementation.
#
# Non-source files (configs, docs, lockfiles, .env) pass through to a real Read.
# A redirect to a file codegraph hasn't indexed SELF-CORRECTS: the file-view
# replies "No indexed file matches … Read it directly", so a just-created file
# never dead-ends — the agent Reads it on the next turn.
#
# Wire via: claude ... --settings <settings-with-this-as-PreToolUse(Read)>
# Eval artifact only. The production version is an indexed-aware `codegraph`
# subcommand (cross-platform — no bash/jq — and queries the index so it never
# bounces a new/un-indexed file), wired opt-in by the installer.
set -uo pipefail
input="$(cat)"
fp="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
[ -n "$fp" ] || exit 0
base="$(basename "$fp")"
case "$fp" in
*.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs|*.py|*.go|*.rs|*.java|*.rb|*.php|*.swift|*.kt|*.kts|*.scala|*.c|*.cc|*.cpp|*.h|*.hpp|*.cs|*.lua|*.vue|*.svelte|*.m|*.mm)
msg="codegraph has this file indexed (kept in sync on every edit). Call codegraph_node with file:\"$base\" and includeCode:true instead of Read — it returns the WHOLE file verbatim WITH line numbers (imports, top-level code and all — safe to base an Edit on) PLUS which files depend on it, in one call. Treat its output as already-Read; do not Read this file. (If it answers that the file isn't indexed — e.g. you just created it — then Read it directly.)"
jq -n --arg m "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$m}}'
exit 0
;;
esac
exit 0
@@ -0,0 +1,119 @@
#!/usr/bin/env node
// Reproduction harness A — does the shared daemon serialize concurrent explore?
//
// Mirrors the daemon's reality: ONE CodeGraph + ONE ToolHandler (as MCPEngine
// shares across all sessions), then fires N concurrent codegraph_explore calls
// and measures:
// - each call's wall-clock latency + completion order
// - an event-loop HEARTBEAT (setInterval 50ms): the max gap between ticks is a
// direct measure of how long synchronous compute blocked the loop. In the
// real daemon a blocked loop can't flush a finished response or read the
// next request, so this gap is what starves the MCP transport.
//
// Usage: node repro-concurrent-explore.mjs <repo-with-.codegraph> <N> [timeoutMs]
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
import { performance } from 'node:perf_hooks';
const [, , repo, nRaw, timeoutRaw] = process.argv;
if (!repo) {
console.error('usage: repro-concurrent-explore.mjs <repo> <N=10> [timeoutMs=60000]');
process.exit(1);
}
const N = Number(nRaw) || 10;
const TIMEOUT_MS = Number(timeoutRaw) || 60000; // ~ MCP SDK default request timeout
const load = async (rel) => import(pathToFileURL(resolve(rel)).href);
const idx = await load('dist/index.js');
const tools = await load('dist/mcp/tools.js');
const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph;
const ToolHandler = tools.ToolHandler ?? tools.default?.ToolHandler;
// Distinct queries so no two calls are trivially identical. Mix of NL questions
// (exercise FTS + RWR over the whole graph) — the expensive explore path.
const QUERIES = [
'how does the text model handle edits and undo',
'how does the file service watch for changes on disk',
'how does the keybinding service resolve a chord to a command',
'how does the extension host activate an extension',
'how does the editor render decorations in the viewport',
'how does the search service stream results to the UI',
'how does the terminal process manager spawn a shell',
'how does the configuration service merge user and workspace settings',
'how does the debug adapter forward breakpoints to the runtime',
'how does the quick input widget filter its items',
'how does the notification service queue and show toasts',
'how does the git extension compute the diff for a file',
'how does the language features registry dispatch a hover request',
'how does the workbench layout restore editor groups on reload',
'how does the storage service persist state between sessions',
'how does the menu service build a context menu from contributions',
];
const cg = CodeGraph.openSync(repo);
let fileCount = 0;
try { fileCount = cg.getStats().fileCount; } catch {}
const handler = new ToolHandler(cg);
// --- event-loop heartbeat ---
let lastTick = performance.now();
let maxGap = 0;
const gaps = [];
const hb = setInterval(() => {
const now = performance.now();
const gap = now - lastTick;
lastTick = now;
if (gap > 60) gaps.push(Math.round(gap)); // expected ~50ms; record stalls
if (gap > maxGap) maxGap = gap;
}, 50);
function runOne(i) {
const q = QUERIES[i % QUERIES.length];
const startedAt = performance.now();
let timer;
const timeout = new Promise((res) => {
timer = setTimeout(() => res({ timedOut: true }), TIMEOUT_MS);
});
const work = handler
.execute('codegraph_explore', { query: q })
.then((r) => ({ ok: !r.isError, chars: r.content?.[0]?.text?.length ?? 0 }))
.catch((e) => ({ ok: false, err: String(e?.message ?? e) }));
return Promise.race([work, timeout]).then((r) => {
clearTimeout(timer);
return { i, q, ms: Math.round(performance.now() - startedAt), ...r };
});
}
// Baseline: one warm single call (so the first-call cold paths don't skew N).
const warmStart = performance.now();
await runOne(0);
const warmMs = Math.round(performance.now() - warmStart);
// Reset heartbeat stats for the concurrent run.
gaps.length = 0; maxGap = 0; lastTick = performance.now();
const batchStart = performance.now();
const results = await Promise.all(Array.from({ length: N }, (_, i) => runOne(i)));
const batchMs = Math.round(performance.now() - batchStart);
clearInterval(hb);
const lat = results.map((r) => r.ms).sort((a, b) => a - b);
const timeouts = results.filter((r) => r.timedOut).length;
const p = (q) => lat[Math.min(lat.length - 1, Math.floor(q * lat.length))];
console.log('='.repeat(64));
console.log(`repo=${repo}`);
console.log(`fileCount=${fileCount} N=${N} perCallTimeout=${TIMEOUT_MS}ms`);
console.log(`single warm explore: ${warmMs}ms`);
console.log('-'.repeat(64));
console.log(`concurrent batch wall-clock: ${batchMs}ms`);
console.log(`per-call latency min=${lat[0]} p50=${p(0.5)} p90=${p(0.9)} max=${lat[lat.length - 1]} (ms)`);
console.log(`TIMEOUTS (>${TIMEOUT_MS}ms): ${timeouts} / ${N}`);
console.log(`event-loop max stall: ${Math.round(maxGap)}ms stalls>60ms: ${gaps.length}`);
console.log(` sum of stalls: ${gaps.reduce((a, b) => a + b, 0)}ms biggest 5: ${gaps.sort((a,b)=>b-a).slice(0,5).join(', ')}`);
console.log('-'.repeat(64));
console.log('SERIALIZATION CHECK:');
console.log(` if serialized, batch ≈ N×single = ~${N * warmMs}ms; actual=${batchMs}ms (ratio ${(batchMs / (N * warmMs)).toFixed(2)})`);
console.log(` max latency / single = ${(lat[lat.length - 1] / warmMs).toFixed(1)}× (≈N means last call waited for all others)`);
console.log('='.repeat(64));
try { cg.close?.(); } catch {}
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env node
// Reproduction harness B — the FAITHFUL opencode scenario.
//
// Spawns N real `codegraph serve --mcp --path <repo>` processes (each becomes a
// proxy that attaches to ONE shared daemon — exactly what opencode does with N
// subagents), drives clean MCP JSON-RPC over each child's stdio, then fires ONE
// concurrent wave of codegraph_explore tools/call across all N and measures
// end-to-end latency + timeouts. This captures transport-flush starvation: a
// daemon event-loop blocked in synchronous explore compute can neither read the
// next request nor flush a finished response.
//
// Usage: node repro-daemon-clients.mjs <repo> <N=10> [perCallTimeoutMs=60000] [warm=1]
import { spawn } from 'node:child_process';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
const [, , repoRaw, nRaw, timeoutRaw, warmRaw] = process.argv;
const repo = resolve(repoRaw || '.');
const N = Number(nRaw) || 10;
const TIMEOUT_MS = Number(timeoutRaw) || 60000;
const WARM = warmRaw === undefined ? true : warmRaw !== '0';
const CLI = resolve('dist/bin/codegraph.js');
const QUERIES = [
'how does the text model handle edits and undo',
'how does the file service watch for changes on disk',
'how does the keybinding service resolve a chord to a command',
'how does the extension host activate an extension',
'how does the editor render decorations in the viewport',
'how does the search service stream results to the UI',
'how does the terminal process manager spawn a shell',
'how does the configuration service merge user and workspace settings',
'how does the debug adapter forward breakpoints to the runtime',
'how does the quick input widget filter its items',
'how does the notification service queue and show toasts',
'how does the git extension compute the diff for a file',
];
function makeClient(id) {
const child = spawn('node', [CLI, 'serve', '--mcp', '--path', repo], {
env: { ...process.env, CODEGRAPH_TELEMETRY: '0', DO_NOT_TRACK: '1', CODEGRAPH_MCP_LOG_ATTACH: '0' },
stdio: ['pipe', 'pipe', 'inherit'],
});
let buf = '';
const waiters = new Map(); // id -> resolve
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
buf += chunk;
let idx;
while ((idx = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
let msg; try { msg = JSON.parse(line); } catch { continue; }
if (msg.id !== undefined && waiters.has(msg.id)) {
waiters.get(msg.id)(msg);
waiters.delete(msg.id);
}
}
});
const send = (obj) => child.stdin.write(JSON.stringify(obj) + '\n');
const request = (method, params, rpcId, timeoutMs) =>
new Promise((res) => {
let timer;
if (timeoutMs) timer = setTimeout(() => { waiters.delete(rpcId); res({ __timeout: true }); }, timeoutMs);
waiters.set(rpcId, (m) => { if (timer) clearTimeout(timer); res(m); });
send({ jsonrpc: '2.0', id: rpcId, method, params });
});
return { id, child, send, request };
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const clients = Array.from({ length: N }, (_, i) => makeClient(i));
// Initialize every client (handshake is answered locally by each proxy, instant).
await Promise.all(clients.map((c) =>
c.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'repro', version: '1' } }, `init-${c.id}`, 10000)
.then(() => c.send({ jsonrpc: '2.0', method: 'initialized' }))
));
// Warm the daemon: one explore through client 0 forces daemon spawn + project
// open + catch-up gate to complete, so the concurrent wave measures the STEADY
// state (the user's real scenario after the first call), not cold start.
if (WARM) {
process.stderr.write('[repro] warming daemon (first explore triggers spawn+open+catchup)...\n');
const t0 = performance.now();
const r = await clients[0].request('tools/call', { name: 'codegraph_explore', arguments: { query: QUERIES[0] } }, 'warm-0', 120000);
process.stderr.write(`[repro] warm explore took ${Math.round(performance.now() - t0)}ms (timeout=${!!r.__timeout})\n`);
await sleep(500);
}
// THE WAVE: fire one explore on every client as simultaneously as possible.
process.stderr.write(`[repro] firing ${N} concurrent explores...\n`);
const waveStart = performance.now();
const results = await Promise.all(clients.map((c, i) => {
const started = performance.now();
return c.request('tools/call', { name: 'codegraph_explore', arguments: { query: QUERIES[i % QUERIES.length] } }, `call-${c.id}`, TIMEOUT_MS)
.then((m) => ({
id: c.id,
ms: Math.round(performance.now() - started),
timedOut: !!m.__timeout,
ok: !!m.result && !m.result.isError,
chars: m.result?.content?.[0]?.text?.length ?? 0,
}));
}));
const waveMs = Math.round(performance.now() - waveStart);
const lat = results.map((r) => r.ms).sort((a, b) => a - b);
const timeouts = results.filter((r) => r.timedOut).length;
const p = (q) => lat[Math.min(lat.length - 1, Math.floor(q * lat.length))];
console.log('='.repeat(64));
console.log(`HARNESS B (real daemon + ${N} proxies) repo=${repo}`);
console.log(`warm=${WARM} perCallTimeout=${TIMEOUT_MS}ms`);
console.log('-'.repeat(64));
console.log(`wave wall-clock: ${waveMs}ms`);
console.log(`per-call latency min=${lat[0]} p50=${p(0.5)} p90=${p(0.9)} max=${lat[lat.length - 1]} (ms)`);
console.log(`TIMEOUTS (>${TIMEOUT_MS}ms): ${timeouts} / ${N}`);
console.log(`completion order (id:ms): ${results.slice().sort((a,b)=>a.ms-b.ms).map(r=>`${r.id}:${r.ms}`).join(' ')}`);
console.log('='.repeat(64));
for (const c of clients) { try { c.child.stdin.end(); c.child.kill('SIGTERM'); } catch {} }
await sleep(300);
process.exit(0);
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Headless Claude Code run against a repo with codegraph MCP, capturing the
# full stream-json so we can see tool calls + token usage. Complements the
# interactive itrun.sh: headless gives a clean per-tool breakdown + exact
# tokens/cost, but defaults to the general-purpose subagent (not Explore).
# To force the Explore path, ask for it in the prompt.
#
# Usage: run-agent.sh <repo-path> <label> "<prompt>"
# Env: AGENT_EVAL_OUT (default /tmp/agent-eval), CG_BIN (codegraph dist binary)
set -uo pipefail
REPO="$1"; LABEL="$2"; PROMPT="$3"
CG_BIN="${CG_BIN:-$(command -v codegraph || echo /usr/local/bin/codegraph)}"
OUT_DIR="${AGENT_EVAL_OUT:-/tmp/agent-eval}"; mkdir -p "$OUT_DIR"
OUT="$OUT_DIR/run-${LABEL}.jsonl"
MCP_CONFIG=$(cat <<JSON
{"mcpServers":{"codegraph":{"command":"${CG_BIN}","args":["serve","--mcp","--path","${REPO}"]}}}
JSON
)
echo "→ running [$LABEL] in $REPO"
cd "$REPO" || exit 1
claude -p "$PROMPT" \
--output-format stream-json --verbose \
--permission-mode bypassPermissions \
--model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" \
--max-budget-usd 2 \
--strict-mcp-config --mcp-config "$MCP_CONFIG" \
> "$OUT" 2>"$OUT_DIR/run-${LABEL}.err"
echo "exit: $? | wrote $OUT ($(wc -l < "$OUT") lines)"
node "$(cd "$(dirname "$0")" && pwd)/parse-run.mjs" "$OUT" 2>/dev/null || true
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# With/without A/B (and optional interactive) eval for a codegraph version on a
# repo. Codegraph is the ONLY variable: both arms launch claude with
# --strict-mcp-config — with = codegraph-only MCP (pointed at $CG_BIN),
# without = empty MCP. Built-in Read/Grep/Bash stay available in both arms.
#
# Usage: run-all.sh <repo-path> "<question>" [headless|tmux|all]
# Env: CG_BIN codegraph binary (default: command -v codegraph)
# AGENT_EVAL_OUT output dir (default: /tmp/agent-eval)
# MODEL / EFFORT claude model/effort (default: sonnet / high — the
# standing A/B policy; see CLAUDE.md, don't raise)
set -uo pipefail
REPO="${1:?usage: run-all.sh <repo-path> \"<question>\" [headless|tmux|all]}"
Q="${2:?question required}"
MODE="${3:-headless}"
CG_BIN="${CG_BIN:-$(command -v codegraph)}"
OUT="${AGENT_EVAL_OUT:-/tmp/agent-eval}"
HARNESS="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$OUT"
# Neutralize any ambient CodeGraph prompt-hook (~/.claude) in BOTH arms:
# the hook injects codegraph context into every prompt, which contaminates
# the without-arm (free structural context) and double-counts the with-arm.
# The A/B's only variable must be the MCP server wired below.
export CODEGRAPH_NO_PROMPT_HOOK=1
[ -n "$CG_BIN" ] || { echo "no codegraph binary on PATH (set CG_BIN)"; exit 1; }
[ -d "$REPO/.codegraph" ] || { echo "no .codegraph index at $REPO — index it first"; exit 1; }
case "$MODE" in headless|tmux|all) ;; *) echo "mode must be headless|tmux|all (got '$MODE')"; exit 1;; esac
# MCP config files (path form avoids inline-JSON quoting through tmux).
cat > "$OUT/mcp-codegraph.json" <<JSON
{"mcpServers":{"codegraph":{"command":"$CG_BIN","args":["serve","--mcp","--path","$REPO"]}}}
JSON
echo '{"mcpServers":{}}' > "$OUT/mcp-empty.json"
echo "###### codegraph: $CG_BIN"
echo "###### repo: $REPO"
echo "###### question: $Q"
echo
# Headless arm: claude -p with stream-json -> exact tool sequence + tokens/cost.
headless() {
local label="$1" cfg="$2"
echo "############################## HEADLESS [$label] ##############################"
( cd "$REPO" && claude -p "$Q" \
--output-format stream-json --verbose \
--permission-mode bypassPermissions \
--model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" \
--max-budget-usd 4 \
--strict-mcp-config --mcp-config "$cfg" \
> "$OUT/run-$label.jsonl" 2>"$OUT/run-$label.err" )
echo "exit $? -> $OUT/run-$label.jsonl ($(wc -l < "$OUT/run-$label.jsonl" | tr -d ' ') lines)"
tail -2 "$OUT/run-$label.err" 2>/dev/null
node "$HARNESS/parse-run.mjs" "$OUT/run-$label.jsonl" 2>&1 || true
echo
}
if [ "$MODE" = headless ] || [ "$MODE" = all ]; then
headless "headless-with" "$OUT/mcp-codegraph.json"
headless "headless-without" "$OUT/mcp-empty.json"
fi
if [ "$MODE" = tmux ] || [ "$MODE" = all ]; then
echo "############################## INTERACTIVE [with] ##############################"
CLAUDE_EXTRA_ARGS="--model ${MODEL:-sonnet} --effort ${EFFORT:-high} --strict-mcp-config --mcp-config $OUT/mcp-codegraph.json" \
bash "$HARNESS/itrun.sh" "$REPO" "int-with" "$Q" 2>&1 || echo "[itrun WITH failed]"
echo
echo "############################## INTERACTIVE [without] ##############################"
CLAUDE_EXTRA_ARGS="--model ${MODEL:-sonnet} --effort ${EFFORT:-high} --strict-mcp-config --mcp-config $OUT/mcp-empty.json" \
bash "$HARNESS/itrun.sh" "$REPO" "int-without" "$Q" 2>&1 || echo "[itrun WITHOUT failed]"
echo
fi
echo "############################## RUN-ALL COMPLETE ##############################"
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Tool-surface ablation — run ONE repo+question under ONE arm.
#
# Arms vary (exposed codegraph tools, trace-first steering). Tools are trimmed
# SERVER-SIDE via CODEGRAPH_MCP_TOOLS in the MCP config's `env` block, so an
# ablated tool is genuinely absent from ListTools — no deferred-ToolSearch or
# denied-call confound (which --disallowedTools would introduce). Steering is
# injected with --append-system-prompt, so no rebuild of the shipped
# server-instructions is needed to A/B it.
#
# A control all tools no steering
# B steer all tools trace-first
# C no-explore hide explore trace-first
# D trace-centric hide explore+context trace-first
# E control-probe hide explore+context trace-first (caller passes a NON-flow Q)
#
# Usage: run-arms.sh <repo-path> "<question>" <A|B|C|D|E> [run-id]
set -uo pipefail
REPO="${1:?repo path}"; Q="${2:?question}"; ARM="${3:?arm A-E}"; RID="${4:-1}"
CG_BIN="${CG_BIN:-$(command -v codegraph)}"
OUT="${ARMS_OUT:-/tmp/arms}/$(basename "$REPO")"
mkdir -p "$OUT"
[ -n "$CG_BIN" ] || { echo "no codegraph binary (set CG_BIN)"; exit 1; }
[ -d "$REPO/.codegraph" ] || { echo "no .codegraph index at $REPO"; exit 1; }
STEER='Flow questions ("how does X reach/become Y", "trace the flow", request to handler, state to render): call codegraph_trace(from,to) FIRST — one call returns the whole path. Use codegraph_context/search only to locate the two endpoint symbols if you do not know them. Do NOT reconstruct the path with repeated search/callers/explore.'
KEEP_NO_EXPLORE="trace,search,node,context,callers,callees,impact,files,status"
KEEP_TRACE_CENTRIC="trace,search,node,callers,callees,impact,files,status"
case "$ARM" in
A|G|H|I) TOOLS=""; STEERING="" ;; # no steering; H = body-trace, I = body-trace + destination callees (sufficiency)
B|F) TOOLS=""; STEERING="$STEER" ;; # F = B's surface, run on the body-inlining trace build
C) TOOLS="$KEEP_NO_EXPLORE"; STEERING="$STEER" ;;
D|E) TOOLS="$KEEP_TRACE_CENTRIC"; STEERING="$STEER" ;;
*) echo "bad arm '$ARM' (want A|B|C|D|E)"; exit 1 ;;
esac
CFG="$OUT/mcp-$ARM.json"
if [ -n "$TOOLS" ]; then
cat > "$CFG" <<JSON
{"mcpServers":{"codegraph":{"command":"$CG_BIN","args":["serve","--mcp","--path","$REPO"],"env":{"CODEGRAPH_MCP_TOOLS":"$TOOLS"}}}}
JSON
else
cat > "$CFG" <<JSON
{"mcpServers":{"codegraph":{"command":"$CG_BIN","args":["serve","--mcp","--path","$REPO"]}}}
JSON
fi
LOG="$OUT/$ARM-r$RID.jsonl"; ERR="$OUT/$ARM-r$RID.err"
ARGS=( -p "$Q" --output-format stream-json --verbose
--permission-mode bypassPermissions --model "${MODEL:-sonnet}" --effort "${EFFORT:-high}" --max-budget-usd 4
--strict-mcp-config --mcp-config "$CFG" )
[ -n "$STEERING" ] && ARGS+=( --append-system-prompt "$STEERING" )
( cd "$REPO" && claude "${ARGS[@]}" > "$LOG" 2>"$ERR" )
echo "[$(basename "$REPO") $ARM r$RID] exit $? -> $LOG ($(wc -l < "$LOG" | tr -d ' ') lines)"
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env node
// Mine the surviving A/B stream-json logs (/tmp/ab-matrix/<Cell>/run-headless-*.jsonl)
// for what the aggregate matrix can't see: the call SEQUENCE and per-call output SIZE.
//
// Answers three questions:
// 1. Trace adoption — on a flow question, does the with-arm actually call codegraph_trace?
// 2. Payload size vs repo size — is trace path-scoped (tiny, size-independent) while
// explore is breadth-scoped (grows with the repo / over-returns on small repos)?
// 3. Round-trips — num_turns with vs without (the real wall-clock driver).
//
// Usage: node scripts/agent-eval/seq-matrix.mjs [/tmp/ab-matrix]
import { readFileSync, readdirSync, existsSync } from 'fs';
import { join } from 'path';
const AB = process.argv[2] || '/tmp/ab-matrix';
const MD = new URL('../../docs/benchmarks/codegraph-ab-matrix.md', import.meta.url).pathname;
// repo -> {lang,size,files} from the published matrix table
const repoMeta = {};
if (existsSync(MD)) for (const line of readFileSync(MD, 'utf8').split('\n')) {
const m = line.match(/^\|\s*([^|]+?)\s*\|\s*(S|M|L)\s*\|\s*`([^`]+)`\s*\|\s*(\d+)\s*\|/);
if (m) repoMeta[m[3]] = { lang: m[1].trim(), size: m[2], files: +m[4] };
}
const cgShort = (n) => n.replace('mcp__codegraph__codegraph_', '').replace('mcp__codegraph__', '');
const tag = (n) => n === 'Read' ? 'R' : n === 'Grep' ? 'G' : n === 'Glob' ? 'Gl'
: n === 'Bash' ? 'B' : n === 'Task' ? 'Ag' : n === 'ToolSearch' ? 'TS'
: n.includes('codegraph') ? cgShort(n) : n;
function parse(file) {
if (!existsSync(file)) return null;
const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
const calls = []; let result = null, initCg = 0;
for (const l of lines) {
let ev; try { ev = JSON.parse(l); } catch { continue; }
if (ev.type === 'system' && ev.subtype === 'init') initCg = (ev.tools || []).filter(t => /codegraph/.test(t)).length;
if (ev.type === 'assistant') for (const b of (ev.message?.content || [])) if (b.type === 'tool_use') {
const i = b.input || {};
const q = i.query ?? i.symbol ?? i.task ?? (i.from && i.to ? `${i.from}->${i.to}` : (i.file_path || i.command || ''));
calls.push({ id: b.id, name: b.name, q: String(q ?? '').slice(0, 38), out: 0 });
}
if (ev.type === 'user') for (const b of (ev.message?.content || [])) if (b.type === 'tool_result') {
const c = b.content;
const txt = typeof c === 'string' ? c : Array.isArray(c) ? c.map(x => x?.text || '').join('') : '';
const call = calls.find(k => k.id === b.tool_use_id); if (call) call.out = txt.length;
}
if (ev.type === 'result') result = ev;
}
const cg = calls.filter(c => c.name.includes('codegraph'));
const perTool = {};
for (const c of cg) { const k = cgShort(c.name); (perTool[k] ??= { n: 0, out: 0 }); perTool[k].n++; perTool[k].out += c.out; }
const traceIdx = cg.findIndex(c => c.name.includes('trace'));
const u = result?.usage || {};
return {
initCg, cg, perTool,
cgSeq: cg.map(c => cgShort(c.name)),
seq: calls.map(c => tag(c.name)),
reads: calls.filter(c => c.name === 'Read').length,
greps: calls.filter(c => c.name === 'Grep').length,
cgOut: cg.reduce((s, c) => s + c.out, 0),
traceUsed: traceIdx >= 0,
afterTrace: traceIdx >= 0 ? cg.slice(traceIdx + 1).map(c => cgShort(c.name)) : null,
turns: result?.num_turns ?? null,
dur: result?.duration_ms ? Math.round(result.duration_ms / 1000) : null,
cost: result?.total_cost_usd || 0,
};
}
const cells = [];
for (const d of readdirSync(AB)) {
const dir = join(AB, d);
if (!existsSync(join(dir, 'run-headless-with.jsonl'))) continue;
const log = existsSync(join(AB, d + '.log')) ? readFileSync(join(AB, d + '.log'), 'utf8') : '';
const repo = (log.match(/repo:\s*\S*\/([^\s/]+)/) || [])[1] || d;
const question = (log.match(/question:\s*(.+)/) || [])[1] || '';
cells.push({ cell: d, repo, question, ...(repoMeta[repo] || {}),
with: parse(join(dir, 'run-headless-with.jsonl')),
without: parse(join(dir, 'run-headless-without.jsonl')) });
}
cells.sort((a, b) => (a.files || 0) - (b.files || 0));
const k = (n) => (n / 1000).toFixed(1);
const pad = (s, n) => String(s).padEnd(n);
// ---- per-cell sequence table ----
console.log('\n=== PER-CELL: with-arm codegraph sequence + payload (sorted by repo size) ===');
console.log(pad('repo', 22), pad('files', 6), 'trace', pad('cg-call sequence', 40), pad('cgOutK', 7), 'turns(w/wo)');
for (const c of cells) {
const w = c.with;
console.log(
pad(c.repo, 22), pad(c.files ?? '?', 6),
pad(w.traceUsed ? 'YES' : 'no', 5),
pad(w.cgSeq.join(',') || '(none)', 40),
pad(k(w.cgOut), 7),
`${w.turns}/${c.without?.turns}`,
);
}
// ---- trace adoption ----
const flow = cells; // every matrix question is a canonical flow question by design
const used = flow.filter(c => c.with.traceUsed);
console.log(`\n=== TRACE ADOPTION (all ${flow.length} cells are flow questions) ===`);
console.log(`trace called in ${used.length}/${flow.length} cells`);
console.log('used trace:', used.map(c => c.repo).join(', ') || '(none)');
if (used.length) console.log('after-trace follow-ups:', used.map(c => `${c.repo}[${c.with.afterTrace.join(',') || 'none'}]`).join(' '));
// ---- payload size by repo-size tier ----
const tier = (f) => f < 200 ? 'S(<200)' : f < 2000 ? 'M(<2000)' : 'L(>=2000)';
const byTier = {};
for (const c of cells) { (byTier[tier(c.files || 0)] ??= []).push(c.with.cgOut); }
console.log('\n=== with-arm TOTAL codegraph payload by repo-size tier ===');
for (const t of ['S(<200)', 'M(<2000)', 'L(>=2000)']) {
const a = byTier[t] || []; if (!a.length) continue;
const avg = a.reduce((s, x) => s + x, 0) / a.length;
console.log(` ${pad(t, 10)} n=${a.length} avg cgOut=${k(avg)}K range ${k(Math.min(...a))}-${k(Math.max(...a))}K`);
}
// ---- per-tool usage + avg payload (breadth vs path evidence) ----
const tot = {};
for (const c of cells) for (const [name, v] of Object.entries(c.with.perTool)) {
(tot[name] ??= { n: 0, out: 0 }); tot[name].n += v.n; tot[name].out += v.out;
}
console.log('\n=== codegraph tool usage across all cells (n calls, avg payload/call) ===');
for (const [name, v] of Object.entries(tot).sort((a, b) => b[1].n - a[1].n)) {
console.log(` ${pad(name, 10)} calls=${pad(v.n, 4)} avg=${k(v.out / v.n)}K/call total=${k(v.out)}K`);
}
// ---- round-trips ----
const sum = (arr, f) => arr.reduce((s, x) => s + (f(x) || 0), 0);
const wTurns = sum(cells, c => c.with.turns), woTurns = sum(cells, c => c.without?.turns);
const wCalls = sum(cells, c => c.with.cg.length);
const tsAll = cells.every(c => c.with.seq[0] === 'TS');
console.log('\n=== ROUND-TRIPS ===');
console.log(`turns: with=${wTurns} without=${woTurns} (${((1 - wTurns / woTurns) * 100).toFixed(0)}% fewer with)`);
console.log(`avg turns/cell: with=${(wTurns / cells.length).toFixed(1)} without=${(woTurns / cells.length).toFixed(1)}`);
console.log(`total codegraph calls=${wCalls} (avg ${(wCalls / cells.length).toFixed(1)}/cell)`);
console.log(`every with-arm opens with a ToolSearch round-trip (deferred tools): ${tsAll ? 'YES — 1 fixed tax/run' : 'no'}`);