diff --git a/container-entrypoint.sh b/container-entrypoint.sh index d7fb47b..301d7c4 100755 --- a/container-entrypoint.sh +++ b/container-entrypoint.sh @@ -53,11 +53,49 @@ if [ ! -f "$PROMPT_FILE" ]; then fi PROMPT=$(cat "$PROMPT_FILE") +# ── Step 3b: Pre-clone repo from reference if available ────────── +REPO_NAME="${CLAUDEBOX_REPO_NAME:-}" +BASE_BRANCH="${CLAUDEBOX_BASE_BRANCH:-next}" +REPO_DIR="/workspace/${REPO_NAME}" + +if [ -n "$REPO_NAME" ] && [ -d "/reference-repo/.git" ] && [ ! -d "${REPO_DIR}/.git" ]; then + echo "" + echo "━━━ Pre-cloning $REPO_NAME (sparse) from reference ━━━" + git config --global --add safe.directory /reference-repo/.git + git config --global --add safe.directory "$REPO_DIR" + # Sparse clone: only checkout .claude/ dirs (skills, settings, CLAUDE.md) + # Claude will populate the full tree via clone_repo when it needs to work + git clone --shared --no-checkout /reference-repo/.git "$REPO_DIR" 2>&1 || true + if [ -d "${REPO_DIR}/.git" ]; then + cd "$REPO_DIR" + # Autodetect .claude-related paths from the reference repo + CLAUDE_PATHS=$(git ls-tree -r -d --name-only HEAD 2>/dev/null | grep -E '(^\.claude$|/\.claude$)' || true) + if [ -n "$CLAUDE_PATHS" ]; then + git sparse-checkout set --cone $CLAUDE_PATHS 2>/dev/null || true + else + git sparse-checkout set --cone .claude 2>/dev/null || true + fi + # Try to checkout the base branch + git checkout --detach "origin/${BASE_BRANCH}" 2>/dev/null \ + || git checkout --detach origin/main 2>/dev/null \ + || git checkout --detach HEAD 2>/dev/null \ + || true + echo "Pre-cloned (sparse) at $(git rev-parse --short HEAD 2>/dev/null || echo '???')" + fi +elif [ -n "$REPO_NAME" ] && [ -d "${REPO_DIR}/.git" ]; then + echo "Repo already exists at $REPO_DIR" + cd "$REPO_DIR" +fi + +# Set working directory: prefer repo dir if it exists, else /workspace +WORK_DIR="$REPO_DIR" +[ ! -d "$WORK_DIR" ] && WORK_DIR="/workspace" + echo "" echo "━━━ Launching Claude ━━━" echo "" -cd /workspace +cd "$WORK_DIR" # ── Step 4: Run Claude (or CLAUDE_BINARY override) ──────────────── CLAUDE_BIN="${CLAUDE_BINARY:-claude}" diff --git a/packages/libclaudebox/config.ts b/packages/libclaudebox/config.ts index e2596d5..8f93441 100644 --- a/packages/libclaudebox/config.ts +++ b/packages/libclaudebox/config.ts @@ -29,10 +29,13 @@ export const SESSION_PAGE_USER = process.env.CLAUDEBOX_SESSION_USER || "admin"; export const SESSION_PAGE_PASS = process.env.CLAUDEBOX_SESSION_PASS || ""; // ── Log URL builder ───────────────────────────────────────────── -// Override with CLAUDEBOX_LOG_BASE_URL to change from default. -export const LOG_BASE_URL = process.env.CLAUDEBOX_LOG_BASE_URL || `http://${CLAUDEBOX_HOST}`; +// Points to the session page on CLAUDEBOX_HOST (e.g. claudebox.work/s/). +// The logId format is -, so we extract the worktreeId prefix. +export const LOG_BASE_URL = `https://${CLAUDEBOX_HOST}`; export function buildLogUrl(logId: string): string { - return `${LOG_BASE_URL}/${logId}`; + // Extract worktreeId from logId (e.g. "d9441073aae158ae-3" → "d9441073aae158ae") + const worktreeId = logId.replace(/-\d+$/, ""); + return `${LOG_BASE_URL}/s/${worktreeId}`; } export const DEFAULT_BASE_BRANCH = process.env.CLAUDEBOX_DEFAULT_BRANCH || "main"; diff --git a/packages/libclaudebox/docker.ts b/packages/libclaudebox/docker.ts index 99f7ccf..b6933ae 100644 --- a/packages/libclaudebox/docker.ts +++ b/packages/libclaudebox/docker.ts @@ -256,6 +256,7 @@ export class DockerService { `CLAUDEBOX_LINK=${opts.link || ""}`, `CLAUDEBOX_HOST=${CLAUDEBOX_HOST}`, `CLAUDEBOX_BASE_BRANCH=${baseBranch}`, + `CLAUDEBOX_REPO_NAME=${basename(REPO_DIR)}`, `CLAUDEBOX_QUIET=${opts.quiet ? "1" : "0"}`, `CLAUDEBOX_CI_ALLOW=${opts.ciAllow ? "1" : "0"}`, `CLAUDEBOX_PROFILE=${profileDir}`, diff --git a/packages/libclaudebox/html/shared.ts b/packages/libclaudebox/html/shared.ts index 7cfa375..a23051e 100644 --- a/packages/libclaudebox/html/shared.ts +++ b/packages/libclaudebox/html/shared.ts @@ -1,4 +1,4 @@ -export type { RunMeta } from "../types.ts"; +export type { SessionMeta } from "../types.ts"; export function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); @@ -65,8 +65,8 @@ export interface ActivityEntry { export interface WorkspacePageData { hash: string; // current session log_id - session: RunMeta; // current session - sessions: RunMeta[]; // all sessions for this worktree (newest first) + session: SessionMeta; // current session + sessions: SessionMeta[]; // all sessions for this worktree (newest first) worktreeAlive: boolean; activity: ActivityEntry[]; // newest first /** Last reply text per session log_id (for collapsed run card summaries) */ @@ -186,7 +186,11 @@ export function renderActivityEntry(a: ActivityEntry, agentLogUrl?: string): str const toolArgs = spIdx > 0 ? linkify(raw.slice(spIdx)) : ""; return `
\u25B8${toolName}${toolArgs}${timeStr}
`; } else if (a.type === "tool_result") { - return `
\u25C2${linked}${timeStr}
`; + const raw = a.text || ""; + const isErr = raw.includes("") || raw.startsWith("Error:") || raw.startsWith("ERROR:"); + const cleaned = linkify(raw.replace(/<\/?tool_use_error>/g, "").trim()); + const errStyle = isErr ? ";color:#E94560;border-left-color:#E94560" : ""; + return `
${cleaned}${timeStr}
`; } else if (a.type === "status") { return `
\u25CB${linked}${timeStr}
`; } diff --git a/packages/libclaudebox/html/workspace.ts b/packages/libclaudebox/html/workspace.ts index 26642e4..71a0469 100644 --- a/packages/libclaudebox/html/workspace.ts +++ b/packages/libclaudebox/html/workspace.ts @@ -1,4 +1,4 @@ -import type { RunMeta } from "../types.ts"; +import type { SessionMeta } from "../types.ts"; import { esc, safeHref, timeAgo, statusColor, linkify, renderActivityEntry, BASE_STYLES, type ActivityEntry, type WorkspacePageData } from "./shared.ts"; import { appShell } from "./app-shell.ts"; @@ -10,7 +10,7 @@ code,.mono{font-family:'SF Mono',Monaco,'Cascadia Code',monospace;font-size:0.85 a{color:inherit;text-decoration:none}a:hover{text-decoration:underline} .link{color:#7ab8ff;text-decoration:underline;text-decoration-color:rgba(122,184,255,0.3)} .link:hover{text-decoration-color:#7ab8ff} -.dim{color:#666} +.dim{color:#9CA3AF} ::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:#333;border-radius:3px} /* Header */ @@ -39,7 +39,7 @@ a{color:inherit;text-decoration:none}a:hover{text-decoration:underline} .sidebar-section{padding:12px 14px;border-bottom:1px solid #1a1a1a} .sidebar-label{font-size:10px;text-transform:uppercase;letter-spacing:0.8px;color:#555;margin-bottom:8px;font-weight:600} .stat-row{font-size:13px;padding:3px 0;display:flex;gap:8px} -.stat-row .dim{min-width:55px;font-size:12px} +.stat-row .dim{min-width:55px;font-size:12px;color:#9CA3AF} .artifact-row{font-size:12px;padding:4px 0;border-bottom:1px solid #111;word-break:break-all;line-height:1.5} /* Session history */ @@ -63,20 +63,36 @@ a{color:inherit;text-decoration:none}a:hover{text-decoration:underline} .chat-area{flex:1;overflow-y:auto;padding:12px 20px;display:flex;flex-direction:column;gap:6px;min-height:0} .chat-empty{flex:1;display:flex;align-items:center;justify-content:center;color:#333;font-size:14px} -/* Flat activity rows (detail view) */ -.act-row{display:flex;align-items:center;gap:6px;font-size:12px;color:#666;padding:3px 0;font-family:'SF Mono',monospace} -.act-ts{color:#444;font-size:10px;margin-left:auto;flex-shrink:0} -.act-icon{font-size:10px;flex-shrink:0;width:14px;text-align:center} +/* Activity rows */ +.act-row{display:flex;align-items:center;gap:8px;font-size:12px;color:#9CA3AF;padding:6px 8px;font-family:'SF Mono',monospace;border-radius:6px;margin:1px 0} +.act-row:hover{background:rgba(255,255,255,0.03)} +.act-ts{color:#6B7280;font-size:10px;margin-left:auto;flex-shrink:0} +.act-icon{flex-shrink:0;width:16px;height:16px;display:flex;align-items:center;justify-content:center;opacity:0.7} +.act-icon svg{width:14px;height:14px} .act-row.act-artifact{color:#FAD979} .act-row.act-agent{color:#a78bfa} +.act-agent-card{background:#13111e;border:1px solid rgba(167,139,250,0.15);border-radius:6px;margin:4px 0;overflow:hidden} +.act-agent-header{display:flex;align-items:center;gap:8px;padding:6px 10px;cursor:pointer;font-size:12px;font-family:'SF Mono',monospace;color:#a78bfa} +.act-agent-header:hover{background:rgba(167,139,250,0.05)} +.act-agent-chevron{font-size:10px;color:#666;transition:transform 0.15s;flex-shrink:0} +.act-agent-card.expanded .act-agent-chevron{transform:rotate(90deg)} +.act-agent-body{display:none;border-top:1px solid rgba(167,139,250,0.1);padding:4px 8px} +.act-agent-card.expanded .act-agent-body{display:block} .agent-dot-inline{width:6px;height:6px;border-radius:50%;background:#a78bfa;display:inline-block;animation:pulse 2s infinite} .tool-name{color:#FAD979;font-weight:500} -.tool-args{color:#888} +.tool-args{color:#9CA3AF} .tool-bash{color:#61D668} -.tool-desc{color:#666;font-style:italic} -.act-result{background:rgba(255,255,255,0.02);border-left:2px solid #222;margin:2px 0 2px 18px;padding:4px 10px;font-size:12px;color:#666;font-family:'SF Mono',monospace;line-height:1.5;white-space:pre-wrap;word-break:break-all;max-height:80px;overflow:hidden} +.tool-desc{color:#7B8794;font-style:italic} +/* Tool execution group — left border links tool call with its result */ +.act-tool-group{border-left:2px solid rgba(250,217,121,0.2);margin-left:6px;padding-left:6px;margin-bottom:2px} +.act-tool-group.act-tool-group-error{border-left-color:rgba(233,69,96,0.4)} +.act-tool-group.act-tool-group-bash{border-left-color:rgba(97,214,104,0.25)} +.act-result{background:#1E1E24;border:1px solid #333;border-radius:6px;margin:2px 0 4px 0;padding:6px 10px;font-size:11px;color:#9CA3AF;font-family:'SF Mono',monospace;line-height:1.5;white-space:pre-wrap;word-break:break-all;max-height:60px;overflow:hidden;cursor:pointer;transition:max-height 0.15s} +.act-result.expanded{max-height:none} +.act-result.act-result-error{border-color:rgba(233,69,96,0.4);color:#E94560;background:rgba(233,69,96,0.06)} +.act-row.act-status{color:#5FA7F1} .artifact-chips{display:flex;gap:6px;flex-wrap:wrap;padding:4px 20px 8px} -.artifact-chip{font-size:12px;padding:2px 8px;border-radius:4px;background:rgba(250,217,121,0.06);border:1px solid rgba(250,217,121,0.15)} +.artifact-chip{font-size:12px;padding:2px 8px;border-radius:4px;background:rgba(250,217,121,0.06);border:1px solid rgba(250,217,121,0.15);line-height:1.6} .artifact-chip-update{background:rgba(95,167,241,0.08);border-color:rgba(95,167,241,0.2)} .artifact-chip-update .artifact-link{color:#7ab8ff} .artifact-link{color:#FAD979;text-decoration:underline;text-decoration-color:rgba(250,217,121,0.3)} @@ -97,10 +113,9 @@ a{color:inherit;text-decoration:none}a:hover{text-decoration:underline} .act-slack-link:hover{color:#7ab8ff;text-decoration:underline} /* Run cards (list view) */ -.run-card{border:1px solid #1a1a1a;border-radius:8px;margin:8px 0;cursor:pointer;transition:border-color 0.2s,box-shadow 0.2s} +.run-card{border:1px solid #1a1a1a;border-radius:8px;margin:8px 0;cursor:pointer;transition:border-color 0.2s,box-shadow 0.2s,background 0.15s} .run-card:hover{border-color:#333;box-shadow:0 0 12px rgba(255,255,255,0.02)} -.run-card-selected{border-color:#5FA7F1;cursor:pointer;background:rgba(95,167,241,0.06) !important} -.run-card-selected .run-header::after{content:'click to open \\2192';color:#5FA7F1;font-size:22px;margin-left:auto;font-weight:600} +.run-card:active{background:rgba(95,167,241,0.08) !important;border-color:#5FA7F1;transition:none} .run-header{padding:12px 20px;display:flex;align-items:center;gap:10px;font-size:13px;color:#888;user-select:none} /* Run detail view */ @@ -116,6 +131,7 @@ a{color:inherit;text-decoration:none}a:hover{text-decoration:underline} .run-label{font-weight:600;color:#ccc;white-space:nowrap} .run-status{color:#666;font-size:12px} .run-exit{color:#E94560;font-size:12px} +.run-slack-link{color:#555;font-size:11px;text-decoration:none;padding:2px 4px;border-radius:3px}.run-slack-link:hover{color:#5FA7F1;background:rgba(95,167,241,0.1)} .run-time{color:#444;margin-left:auto;font-size:11px;flex-shrink:0} .run-summary{padding:8px 20px 16px;font-size:14px;line-height:1.6;cursor:pointer} .run-summary-prompt{color:#ccc;word-break:break-word;white-space:pre-wrap;display:flex;gap:10px} @@ -130,8 +146,8 @@ a{color:inherit;text-decoration:none}a:hover{text-decoration:underline} .md-content h1{font-size:1.15em}.md-content h2{font-size:1.08em}.md-content h3{font-size:1em} .md-content strong{color:#ddd;font-weight:600} .md-content em{font-style:italic;color:#bbb} -.md-content code{background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);padding:1px 5px;border-radius:3px;font-family:'SF Mono',Monaco,'Cascadia Code',monospace;font-size:0.88em;color:#e0c46c} -.md-content pre{background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:6px;padding:10px 14px;margin:8px 0;overflow-x:auto;line-height:1.5} +.md-content code{background:#1E1E24;border:1px solid #333;padding:1px 5px;border-radius:4px;font-family:'SF Mono',Monaco,'Cascadia Code',monospace;font-size:0.88em;color:#e0c46c} +.md-content pre{background:#1E1E24;border:1px solid #333;border-radius:6px;padding:10px 14px;margin:8px 0;overflow-x:auto;line-height:1.5} .md-content pre code{background:none;border:none;padding:0;color:#ccc;font-size:0.88em} .md-content ul,.md-content ol{padding-left:20px;margin:4px 0} .md-content li{margin:2px 0} @@ -209,6 +225,7 @@ export function workspacePageHTML(data: WorkspacePageData): string { slackDomain, sessions: data.sessions.map(s => ({ log_id: s._log_id, status: s.status, started: s.started, user: s.user, + exit_code: s.exit_code ?? null, prompt: stripSlackContext(s.prompt || ""), slack_channel: s.slack_channel || "", slack_message_ts: s.slack_message_ts || "", @@ -410,7 +427,36 @@ function useSSE(id, onMessage){ },[id]); } -// ── Activity row component (flat, no indentation) ─────────────── +// ── SVG icons (inline, 14x14) ──────────────────────────────────── + +var ICONS={ + terminal:'', + search:'', + file:'', + edit:'', + git:'', + tool:'', + status:'', + agent:'', + pr:'', + clone:'', +}; + +function toolIcon(raw){ + if(!raw)return ICONS.tool; + var name=raw.split(" ")[0]; + if(raw.indexOf("$ ")>=0)return ICONS.terminal; + if(name==="Grep"||name==="ToolSearch")return ICONS.search; + if(name==="Read"||name==="Glob"||name==="Write")return ICONS.file; + if(name==="Edit")return ICONS.edit; + if(name==="clone_repo")return ICONS.clone; + if(name==="create_pr"||name==="update_pr")return ICONS.pr; + if(name==="git_fetch"||name==="git_pull")return ICONS.git; + if(name==="session_status")return ICONS.status; + return ICONS.tool; +} + +// ── Activity row component ─────────────────────────────────────── function ActivityRow({entry, agentLogUrl}){ const t=entry.ts?timeAgo(entry.ts):""; @@ -427,35 +473,47 @@ function ActivityRow({entry, agentLogUrl}){ } if(entry.type==="artifact"){ const compact=compactArtifact(text); - return html\`
\u25C6\${t}
\`; + return html\`
\${t}
\`; } if(entry.type==="agent_start"){ - const agentInner=agentLogUrl - ?'Agent: '+linkify(text)+'' - :'Agent: '+linkify(text); - return html\`
\${t}
\`; + return null; // Rendered by AgentSection wrapper } if(entry.type==="tool_use"){ const raw=entry.text||""; + const icon=toolIcon(raw); const bashMatch=raw.match(/^(?:(.+?):\s*)?\$\s+(.+)$/); if(bashMatch){ const desc=bashMatch[1]||""; const cmd=bashMatch[2]; - return html\`
\u25B8\${desc && html\`\${desc} \`}$ \${cmd}\${t}
\`; + return html\`
\${desc && html\`\${desc} \`}$ \${cmd}\${t}
\`; } const spIdx=raw.indexOf(" "); const toolName=spIdx>0?raw.slice(0,spIdx):raw; const toolArgs=spIdx>0?raw.slice(spIdx):""; const argsHtml=linkify(toolArgs); - return html\`
\u25B8\${toolName}\${t}
\`; + return html\`
\${toolName}\${t}
\`; } if(entry.type==="tool_result"){ - return html\`
\`; + var raw=entry.text||""; + var errTag="tool_use_error"; + var isErr=raw.indexOf(errTag)>=0||raw.indexOf("Error:")===0||raw.indexOf("ERROR:")===0; + var cleaned=raw; + if(isErr){cleaned=cleaned.split("<"+errTag+">").join("").split("<"+"/"+errTag+">").join("").trim();} + var resultHtml=linkify(cleaned); + var errCls=isErr?" act-result-error":""; + function toggleExpand(e){e.currentTarget.classList.toggle("expanded");} + return html\`
\`; } if(entry.type==="status"){ - return html\`
\u25CB\${t}
\`; + return html\`
\${t}
\`; + } + if(entry.type==="clone"){ + return html\`
\${t}
\`; + } + if(entry.type==="name"){ + return null; } - return html\`
\u00B7\${t}
\`; + return html\`
\${t}
\`; } // ── PromptCard component (flat, with Slack link) ──────────────── @@ -485,6 +543,93 @@ function ArtifactChips({artifacts, priorPrNums}){ })}\`; } +// ── AgentSection — collapsible agent card ───────────────────── +function AgentSection({text, agentLogUrl, time, children}){ + const [expanded,setExpanded]=useState(false); + const linked=linkify(slackToMd(text)); + const agentInner=agentLogUrl + ?''+linked+'' + :linked; + return html\`
+
setExpanded(!expanded)}> + \u25B6 + + + \${time} +
+ \${children&&children.length?html\`
\${children}
\`:null} +
\`; +} + +// ── Group tool_use + tool_result into bordered containers ───── +function renderActivityItems(activity){ + var out=[]; + for(var i=0;i\${grouped}\`); + i=j-1; // skip grouped items + continue; + } + if(entry.type==="tool_use"){ + // Check if next item is a tool_result — if so, group them + var next=(i+1=0; + var isErr=resRaw.indexOf("tool_use_error")>=0||resRaw.indexOf("Error:")===0||resRaw.indexOf("ERROR:")===0; + var groupCls="act-tool-group"+(isErr?" act-tool-group-error":"")+(isBash?" act-tool-group-bash":""); + out.push(html\`
<\${ActivityRow} entry=\${entry} agentLogUrl=\${item.agentLogUrl} /><\${ActivityRow} entry=\${next.entry} agentLogUrl=\${next.agentLogUrl} />
\`); + i++; // skip next + continue; + } + } + out.push(html\`<\${ActivityRow} key=\${key} entry=\${entry} agentLogUrl=\${item.agentLogUrl} />\`); + } + return out; +} + +// Helper to group tool_use+tool_result pairs within a container +function renderToolGroups(items){ + var out=[]; + for(var i=0;i=0; + var isErr=resRaw.indexOf("tool_use_error")>=0||resRaw.indexOf("Error:")===0||resRaw.indexOf("ERROR:")===0; + var groupCls="act-tool-group"+(isErr?" act-tool-group-error":"")+(isBash?" act-tool-group-bash":""); + out.push(html\`
<\${ActivityRow} entry=\${entry} /><\${ActivityRow} entry=\${next.entry} />
\`); + i++; + continue; + } + } + out.push(html\`<\${ActivityRow} key=\${key} entry=\${entry} />\`); + } + return out; +} + // ── Run cards (new architecture: session-driven, not timeline-driven) ── const RUN_COLORS=[ @@ -498,12 +643,13 @@ function RunCard({run, lastReply, selected, onSelect, onOpen, runArtifacts, prio const st=run.status||"unknown"; const replyText=lastReply?slackToMd(lastReply):""; - return html\`
selected?onOpen(run.index):onSelect(run.index)} onDblClick=\${()=>onOpen(run.index)}> + return html\`
onOpen(run.index)}>
run \${run.index+1}\${run.total>1?"/"+run.total:""} \${st} \${run.exitCode!=null&&run.exitCode!==0?html\`exit \${run.exitCode}\`:null} + \${run.slackLink?html\`e.stopPropagation()} title="View in Slack">\u2197\`:null} \${run.started?html\`\${timeAgo(run.started)}\`:null}
@@ -544,12 +690,13 @@ function RunDetail({run, activity, onBack, runArtifacts, priorPrNums}){ run \${run.index+1}/\${run.total} \${st} \${run.exitCode!=null&&run.exitCode!==0?html\`exit \${run.exitCode}\`:null} + \${run.slackLink?html\`\u2197 Slack\`:null} \${run.started?html\`\${timeAgo(run.started)}\`:null}
\${runArtifacts&&runArtifacts.length?html\`<\${ArtifactChips} artifacts=\${runArtifacts} priorPrNums=\${priorPrNums} />\`:null}
\${run.prompt?html\`<\${PromptCard} text=\${run.prompt} time=\${run.started?timeAgo(run.started):""} user=\${run.user} slackLink=\${run.slackLink} />\`:null} - \${activity.map((item,i)=>html\`<\${ActivityRow} key=\${item.id||("a"+i)} entry=\${item.entry} agentLogUrl=\${item.agentLogUrl} />\`)} + \${renderActivityItems(activity)} \${run.status==="running"?html\`<\${TypingIndicator} />\`:null}
\`; @@ -576,7 +723,7 @@ function ReplyBar({isRunning, onSend, onQueue, onCancel}){ const inputRef=useRef(null); const handleKeyDown=useCallback((e)=>{ - if(e.key==="Enter"&&(e.ctrlKey||e.metaKey)){ + if(e.key==="Enter"&&!e.shiftKey){ e.preventDefault(); const text=(inputRef.current&&inputRef.current.value.trim())||""; if(isRunning){if(text)onQueue(text);} @@ -605,7 +752,7 @@ function ReplyBar({isRunning, onSend, onQueue, onCancel}){ },[onQueue]); return html\`
- +
\${isRunning ?html\`\` @@ -726,15 +873,21 @@ function WorkspacePage(){ function slackPermalink(s){ if(!s.slack_channel)return null; - var ts=s.slack_thread_ts||s.slack_message_ts; + // Use message_ts (specific run message) if available, fall back to thread_ts + var ts=s.slack_message_ts||s.slack_thread_ts; if(!ts)return null; var pTs=ts.replace(".",""); var domain=D.slackDomain||"app"; - return "https://"+domain+".slack.com/archives/"+s.slack_channel+"/p"+pTs; + var url="https://"+domain+".slack.com/archives/"+s.slack_channel+"/p"+pTs; + // If linking to a specific message in a thread, add thread_ts param + if(s.slack_message_ts&&s.slack_thread_ts&&s.slack_message_ts!==s.slack_thread_ts){ + url+="?thread_ts="+s.slack_thread_ts+"&cid="+s.slack_channel; + } + return url; } - // Build runs from D.sessions (oldest first) - const runs=useMemo(()=>{ + // Build runs from D.sessions (oldest first) — mutable so resume can add new runs + const [runs,setRuns]=useState(()=>{ const sessionsOldest=[...D.sessions].reverse(); const total=sessionsOldest.length; return sessionsOldest.map((s,i)=>({ @@ -743,7 +896,7 @@ function WorkspacePage(){ prompt:s.prompt||"", user:s.user||D.user, slackLink:slackPermalink(s), })); - },[]); + }); // Deeplink: ?run= opens detail view, otherwise list view (null) const [selectedRun,setSelectedRun]=useState(()=>{ @@ -780,6 +933,10 @@ function WorkspacePage(){ // Assign activity to a run by timestamp function assignRunLogId(entry){ + if(entry.log_id){ + var known=D.sessions.find(function(s){return s.log_id===entry.log_id;}); + if(known)return entry.log_id; + } const sessionsOldest=[...D.sessions].reverse(); if(!entry.ts||!sessionsOldest.length)return sessionsOldest.length?sessionsOldest[sessionsOldest.length-1].log_id:null; for(let i=sessionsOldest.length-1;i>=0;i--){ @@ -790,6 +947,7 @@ function WorkspacePage(){ // Process entry -> {logId, item} or null function processEntry(e, forceLogId){ + if(!e.type)return null; if(e.type==="agent_log"){ const m=e.text.match(/(https?:\\/\\/[^\\s]+)/); if(m){ @@ -850,14 +1008,35 @@ function WorkspacePage(){ } } + // Add a new run from a resume response and navigate to it + function addRunFromResume(text, d){ + var newLogId=d.log_url?d.log_url.split("/").pop():""; + if(!newLogId)return; + var newSession={log_id:newLogId,status:"running",started:new Date().toISOString(),user:D.user,prompt:text,slack_channel:"",slack_message_ts:"",slack_thread_ts:""}; + D.sessions.unshift(newSession); + var newIdx; + setRuns(prev=>{ + var total=prev.length+1; + newIdx=prev.length; + var updated=prev.map(r=>({...r,total:total})); + updated.push({logId:newLogId,index:newIdx,total:total,status:"running",exitCode:null,started:newSession.started,prompt:text,user:D.user,slackLink:null}); + return updated; + }); + setTimeout(()=>setSelectedRun(newIdx),0); + } + const sendNextQueued=useCallback(()=>{ const q=pendingQueueRef.current; if(!q.length)return; const msg=q[0]; setMessageQueue(prev=>prev.slice(1)); + setStatus("running"); authFetch("/s/"+id+"/resume",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({prompt:msg})}) .then(r=>r.json()) - .then(d=>{if(!d.ok)console.warn("Queue send failed:",d.message);}) + .then(d=>{ + if(!d.ok){console.warn("Queue send failed:",d.message);return;} + addRunFromResume(msg,d); + }) .catch(e=>console.warn("Queue send error:",e)); },[id]); @@ -900,7 +1079,10 @@ function WorkspacePage(){ setStatus("running"); authFetch("/s/"+id+"/resume",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({prompt:text})}) .then(r=>r.json()) - .then(d=>{if(!d.ok)alert(d.message||"Could not resume.");}) + .then(d=>{ + if(!d.ok){alert(d.message||"Could not resume.");return;} + addRunFromResume(text,d); + }) .catch(e=>alert("Error: "+e.message)); },[id]); diff --git a/packages/libclaudebox/mcp/git-tools.ts b/packages/libclaudebox/mcp/git-tools.ts index 26dbe45..4eb45c3 100644 --- a/packages/libclaudebox/mcp/git-tools.ts +++ b/packages/libclaudebox/mcp/git-tools.ts @@ -2,7 +2,7 @@ * Clone and PR tool registration for MCP sidecars. */ -import { execFileSync } from "child_process"; +import { execFileSync, spawnSync } from "child_process"; import { existsSync, writeFileSync, unlinkSync } from "fs"; import { join } from "path"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -15,7 +15,7 @@ import { workspaceName } from "./tools.ts"; // ── Shared clone helper ───────────────────────────────────────── -export function cloneRepoCheckoutAndInit(targetDir: string, ref: string, fallbackRef = "origin/next", opts?: { skipSubmodules?: boolean }): { text: string; isError?: boolean } { +export function cloneRepoCheckoutAndInit(targetDir: string, ref: string, fallbackRef = "origin/next", opts?: { initSubmodules?: boolean }): { text: string; isError?: boolean } { let checkedOutRef = ref; try { execFileSync("git", ["-C", targetDir, "checkout", "--detach", ref], { timeout: 30_000, stdio: "pipe" }); @@ -36,13 +36,15 @@ export function cloneRepoCheckoutAndInit(targetDir: string, ref: string, fallbac const refNote = checkedOutRef !== ref ? ` (WARNING: ${ref} not found, fell back to ${checkedOutRef})` : ""; let submoduleMsg = ""; - if (!opts?.skipSubmodules) { + if (opts?.initSubmodules) { try { execFileSync("git", ["-C", targetDir, "submodule", "update", "--init", "--recursive"], { timeout: 300_000, stdio: "pipe" }); submoduleMsg = " Submodules initialized."; } catch (e: any) { - submoduleMsg = ` ERROR: submodule init failed: ${e.message}. Builds may fail — try running: git submodule update --init --recursive`; + submoduleMsg = ` Warning: submodule init failed: ${e.message}`; } + } else { + submoduleMsg = " Submodules not initialized — use submodule_update tool if needed."; } return { text: `${head}${refNote}.${submoduleMsg}` }; @@ -103,7 +105,7 @@ export interface CloneToolConfig { fallbackRef?: string; refHint?: string; description?: string; - skipSubmodules?: boolean; + initSubmodules?: boolean; } export interface PRToolConfig { @@ -135,11 +137,16 @@ export function registerCloneRepo(server: McpServer, config: CloneToolConfig): v if (existsSync(join(targetDir, ".git"))) { try { + // Disable sparse checkout if active (from pre-clone) to get full working tree + try { + execFileSync("git", ["-C", targetDir, "sparse-checkout", "disable"], { timeout: 10_000, stdio: "pipe" }); + } catch {} + try { execFileSync("git", ["-C", targetDir, "fetch", "origin"], { timeout: 120_000, stdio: "pipe" }); } catch {} - const result = cloneRepoCheckoutAndInit(targetDir, ref, config.fallbackRef, { skipSubmodules: config.skipSubmodules }); + const result = cloneRepoCheckoutAndInit(targetDir, ref, config.fallbackRef, { initSubmodules: config.initSubmodules }); if (result.isError) return { content: [{ type: "text", text: result.text }], isError: true }; return { content: [{ type: "text", text: `Repo already cloned. Checked out ${ref} (${result.text}) Work in ${targetDir}.` }] }; } catch (e: any) { @@ -169,7 +176,7 @@ export function registerCloneRepo(server: McpServer, config: CloneToolConfig): v } finally { try { unlinkSync(askpass); } catch {} } } - const result = cloneRepoCheckoutAndInit(targetDir, ref, config.fallbackRef, { skipSubmodules: config.skipSubmodules }); + const result = cloneRepoCheckoutAndInit(targetDir, ref, config.fallbackRef, { initSubmodules: config.initSubmodules }); if (result.isError) return { content: [{ type: "text", text: `Clone succeeded but: ${result.text}` }], isError: true }; logActivity("clone", `Cloned ${config.repo} at ${ref} (${result.text})`); return { content: [{ type: "text", text: `Cloned ${config.repo} to ${targetDir} at ${ref} (${result.text}) Work in ${targetDir}.` }] }; @@ -179,6 +186,200 @@ export function registerCloneRepo(server: McpServer, config: CloneToolConfig): v }); } +// ── registerGitProxy (git_fetch + git_pull — authenticated via sidecar) ── + +export function registerGitProxy(server: McpServer, config: { workspace: string }): void { + // Helper: run git with GH_TOKEN auth via GIT_ASKPASS + function gitWithAuth(args: string[], timeoutMs = 120_000): string { + const askpass = join("/tmp", `.git-askpass-${process.pid}-${Date.now()}`); + writeFileSync(askpass, `#!/bin/sh\necho "$GIT_PASSWORD"\n`, { mode: 0o700 }); + try { + return execFileSync("git", args, { + timeout: timeoutMs, encoding: "utf-8", stdio: "pipe", + env: { ...process.env, GIT_ASKPASS: askpass, GIT_PASSWORD: process.env.GH_TOKEN || "" }, + }); + } finally { try { unlinkSync(askpass); } catch {} } + } + + server.tool("git_fetch", "Fetch from origin (authenticated). Use this instead of bare `git fetch` which may lack auth for private repos.", + { ref: z.string().optional().describe("Optional refspec to fetch (e.g. 'next', 'pull/123/head:pr-123')") }, + async ({ ref }) => { + const workspace = config.workspace; + if (!existsSync(join(workspace, ".git"))) { + return { content: [{ type: "text", text: "No repo at " + workspace }], isError: true }; + } + try { + const args = ["-C", workspace, "fetch", "origin"]; + if (ref) args.push(ref); + const out = gitWithAuth(args); + return { content: [{ type: "text", text: `Fetched${ref ? " " + ref : ""} successfully.\n${out}`.trim() }] }; + } catch (e: any) { + return { content: [{ type: "text", text: `Fetch failed: ${sanitizeError(e.message)}` }], isError: true }; + } + }); + + server.tool("submodule_update", "Initialize and update git submodules recursively, optionally to a specific commit.", + { + path: z.string().optional().describe("Submodule path (e.g. 'noir/noir-repo'). If omitted, updates all submodules."), + commit: z.string().optional().describe("Checkout submodule to this commit/ref after update"), + }, + async ({ path, commit }) => { + const workspace = config.workspace; + if (!existsSync(join(workspace, ".git"))) { + return { content: [{ type: "text", text: "No repo at " + workspace }], isError: true }; + } + try { + const args = ["-C", workspace, "submodule", "update", "--init", "--recursive"]; + if (path) args.push("--", path); + const out = gitWithAuth(args, 300_000); + let result = "Submodule" + (path ? " " + path : "s") + " initialized and updated.\n" + out; + if (commit && path) { + const subDir = join(workspace, path); + execFileSync("git", ["-C", subDir, "checkout", commit], { timeout: 30_000, encoding: "utf-8", stdio: "pipe" }); + result += "\nChecked out " + path + " to " + commit; + } + return { content: [{ type: "text", text: result.trim() }] }; + } catch (e: any) { + return { content: [{ type: "text", text: `Submodule update failed: ${sanitizeError(e.message)}` }], isError: true }; + } + }); + + server.tool("git_pull", "Pull from origin (authenticated rebase). Use this instead of bare `git pull`.", + { ref: z.string().optional().describe("Remote branch to pull (default: current tracking branch)") }, + async ({ ref }) => { + const workspace = config.workspace; + if (!existsSync(join(workspace, ".git"))) { + return { content: [{ type: "text", text: "No repo at " + workspace }], isError: true }; + } + try { + const args = ["-C", workspace, "pull", "--rebase", "origin"]; + if (ref) args.push(ref); + const out = gitWithAuth(args); + return { content: [{ type: "text", text: `Pulled${ref ? " " + ref : ""} successfully.\n${out}`.trim() }] }; + } catch (e: any) { + return { content: [{ type: "text", text: `Pull failed: ${sanitizeError(e.message)}` }], isError: true }; + } + }); +} + +// ── registerLogTools (read_log + write_log — CI log access) ───── + +export function registerLogTools(server: McpServer, config: { workspace: string }): void { + // Locate ci3/ scripts in the workspace repo + function findCi3(): string | null { + // Check workspace for ci3/ (available after clone_repo) + const wsPath = join(config.workspace, "ci3"); + if (existsSync(join(wsPath, "cache_log"))) return wsPath; + return null; + } + + server.tool("read_log", + `Read a CI log by key/hash. Use this to view build logs, session logs, or any ci.aztec-labs.com log. + +IMPORTANT: Use this tool instead of: +- Curling ci.aztec-labs.com directly (will fail — no CI_PASSWORD in container) +- Using CI_PASSWORD env var (not available) +- Running ci.sh dlog manually + +Pass the log key (hex hash from the URL). Returns the log content.`, + { + key: z.string().regex(/^[a-zA-Z0-9._-]+$/).describe("Log key/hash (the hex string from a ci.aztec-labs.com URL)"), + tail: z.number().optional().describe("Only return the last N lines (useful for large logs)"), + head: z.number().optional().describe("Only return the first N lines"), + }, + async ({ key, tail, head }) => { + const ci3 = findCi3(); + if (!ci3) { + return { content: [{ type: "text", text: "ci3/ not found. Run clone_repo first to make log tools available." }], isError: true }; + } + + try { + // ci.sh dlog sources Redis connection scripts and reads the key + const ciSh = join(config.workspace, "ci.sh"); + const cmd = existsSync(ciSh) ? ciSh : join(ci3, "..", "ci.sh"); + const result = spawnSync("bash", ["-c", `cd "${config.workspace}" && "${cmd}" dlog "${key}"`], { + encoding: "utf-8", + timeout: 30_000, + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env }, + }); + + if (result.status !== 0) { + const err = (result.stderr || "").trim(); + return { content: [{ type: "text", text: `read_log failed (exit ${result.status}): ${err.slice(0, 500)}` }], isError: true }; + } + + let output = result.stdout || ""; + if (!output.trim()) { + return { content: [{ type: "text", text: `Log '${key}' is empty or not found.` }], isError: true }; + } + + // Apply head/tail filtering + if (tail || head) { + const lines = output.split("\n"); + if (tail) output = lines.slice(-tail).join("\n"); + else if (head) output = lines.slice(0, head).join("\n"); + } + + // Truncate very large output + const MAX = 200_000; + if (output.length > MAX) { + output = output.slice(0, MAX) + `\n\n...(truncated at ${MAX} chars, use tail/head params to paginate)`; + } + + return { content: [{ type: "text", text: output }] }; + } catch (e: any) { + return { content: [{ type: "text", text: `read_log: ${sanitizeError(e.message)}` }], isError: true }; + } + }); + + server.tool("write_log", + `Write content to a CI log (ci.aztec-labs.com). Creates a persistent, shareable log link. + +Use this instead of create_gist for: +- Build output and CI logs +- Large command output +- Anything that needs a quick shareable link without creating a GitHub gist + +The log is accessible at ci.aztec-labs.com/. +Returns the log URL.`, + { + content: z.string().describe("Content to write to the log"), + key: z.string().regex(/^[a-zA-Z0-9._-]+$/).optional().describe("Custom key (default: auto-generated). Use descriptive keys like 'build-output-pr-123'."), + category: z.string().default("claudebox").describe("Log category prefix (default: 'claudebox')"), + }, + async ({ content: logContent, key, category }) => { + const ci3 = findCi3(); + if (!ci3) { + return { content: [{ type: "text", text: "ci3/ not found. Run clone_repo first to make log tools available." }], isError: true }; + } + + const cacheLogBin = join(ci3, "cache_log"); + const logKey = key || `claudebox-${SESSION_META.log_id || "manual"}-${Date.now().toString(36)}`; + + try { + const result = spawnSync(cacheLogBin, [category, logKey], { + input: logContent, + encoding: "utf-8", + timeout: 15_000, + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env }, + }); + + if (result.status !== 0) { + const err = (result.stderr || "").trim(); + return { content: [{ type: "text", text: `write_log failed (exit ${result.status}): ${err.slice(0, 500)}` }], isError: true }; + } + + const url = `http://ci.aztec-labs.com/${logKey}`; + logActivity("artifact", `Log: ${url}`); + return { content: [{ type: "text", text: `${url}\nKey: ${logKey}` }] }; + } catch (e: any) { + return { content: [{ type: "text", text: `write_log: ${sanitizeError(e.message)}` }], isError: true }; + } + }); +} + // ── registerPRTools (create_pr + update_pr) ───────────────────── export function registerPRTools(server: McpServer, config: PRToolConfig): void { diff --git a/packages/libclaudebox/mcp/tools.ts b/packages/libclaudebox/mcp/tools.ts index adbaeda..866133d 100644 --- a/packages/libclaudebox/mcp/tools.ts +++ b/packages/libclaudebox/mcp/tools.ts @@ -260,9 +260,13 @@ For writes, use dedicated tools: create_pr, update_pr, create_gist, create_issue } }); - // ── create_gist ────────────────────────────────────────────────── + // ── create_gist / update_gist ─────────────────────────────────── + // Track the session gist — only one create_gist per session, then use update_gist. + let sessionGistId = ""; + let sessionGistUrl = ""; + server.tool("create_gist", - "Create a GitHub gist. Useful for sharing verbose output, logs, or large data that doesn't belong in a Slack message or PR description.", + `Create a GitHub gist. One per session — use update_gist to add files after. Prefer write_log for build output.`, { description: z.string().describe("Short description of the gist"), files: z.record(z.string()).describe("Map of filename → content, e.g. {\"output.log\": \"...\", \"analysis.md\": \"...\"}"), @@ -270,6 +274,9 @@ For writes, use dedicated tools: create_pr, update_pr, create_gist, create_issue }, async ({ description, files, public_gist }) => { if (!hasGhToken()) return { content: [{ type: "text", text: "No GitHub access configured" }], isError: true }; + if (sessionGistId) { + return { content: [{ type: "text", text: `A gist was already created this session: ${sessionGistUrl}\nUse update_gist(gist_id="${sessionGistId}", ...) to add or update files instead of creating another gist.` }], isError: true }; + } const gistFiles: Record = {}; for (const [name, content] of Object.entries(files)) { @@ -278,6 +285,8 @@ For writes, use dedicated tools: create_pr, update_pr, create_gist, create_issue try { const gist = await getCreds().github.createGist({ description, files: gistFiles, public: public_gist }); + sessionGistId = gist.id; + sessionGistUrl = gist.html_url; logActivity("artifact", `Gist: ${gist.html_url}`); otherArtifacts.push(`- [Gist: ${description}](${gist.html_url})`); await updateRootComment(); @@ -287,6 +296,29 @@ For writes, use dedicated tools: create_pr, update_pr, create_gist, create_issue } }); + server.tool("update_gist", + `Update an existing gist — add new files, replace file content, or update the description. Use read_gist first to see existing files if needed.`, + { + gist_id: z.string().describe("Gist ID (from create_gist result or read_gist)"), + description: z.string().optional().describe("New description (optional)"), + files: z.record(z.string()).describe("Map of filename → new content. New filenames add files, existing filenames replace content."), + }, + async ({ gist_id, description, files }) => { + if (!hasGhToken()) return { content: [{ type: "text", text: "No GitHub access configured" }], isError: true }; + + const gistFiles: Record = {}; + for (const [name, content] of Object.entries(files)) { + gistFiles[name] = { content }; + } + + try { + const gist = await getCreds().github.updateGist(gist_id, { description, files: gistFiles }); + return { content: [{ type: "text", text: `Updated: ${gist.html_url}\nFiles: ${Object.keys(files).join(", ")}` }] }; + } catch (e: any) { + return { content: [{ type: "text", text: `update_gist: ${e.message}` }], isError: true }; + } + }); + // ── create_skill ────────────────────────────────────────────── server.tool("create_skill", `Create or update a Claude Code skill and open a draft PR for review. diff --git a/packages/libclaudebox/session-streamer.ts b/packages/libclaudebox/session-streamer.ts index ae2b993..e3cb384 100644 --- a/packages/libclaudebox/session-streamer.ts +++ b/packages/libclaudebox/session-streamer.ts @@ -34,6 +34,7 @@ interface ActivityEvent { ts: string; type: string; text: string; + log_id?: string; } // ── Helpers ───────────────────────────────────────────────────── @@ -97,7 +98,7 @@ export class SessionStreamer { private writeActivity(type: string, text: string): void { try { - const event: ActivityEvent = { ts: new Date().toISOString(), type, text }; + const event: ActivityEvent = { ts: new Date().toISOString(), type, text, log_id: this.opts.parentLogId }; appendFileSync(this.opts.activityLog, JSON.stringify(event) + "\n"); } catch {} } @@ -106,24 +107,22 @@ export class SessionStreamer { this.opts.onOutput?.(text + "\n"); } - private spillToLog(content: string, label: string): string | null { - if (!existsSync(this.cacheLogBin)) return null; + private spillToLog(content: string, label: string): boolean { + if (!existsSync(this.cacheLogBin)) return false; const spillId = randomBytes(16).toString("hex"); - const url = `http://ci.aztec-labs.com/${spillId}`; try { spawnSync(this.cacheLogBin, [`claudebox-${label}`, spillId], { input: content, timeout: 10_000, stdio: ["pipe", "ignore", "ignore"], }); - return url; - } catch { return null; } + return true; + } catch { return false; } } private smartTrunc(s: string, label: string, inlineLimit = 200): string { if (s.length <= SPILL_THRESHOLD) return s; - const url = this.spillToLog(s, label); - if (url) return trunc(s, inlineLimit) + ` → ${url}`; + this.spillToLog(s, label); // archive full content return trunc(s, inlineLimit); } @@ -176,7 +175,7 @@ export class SessionStreamer { this.emit(` ${label}: ${disp}`); // Write tool results to activity for MCP tools (get_context etc.) if (!isSubagent && res.trim()) { - this.writeActivity("tool_result", trunc(res.replace(/\n/g, " "), 300)); + this.writeActivity("tool_result", trunc(res, 600)); } } else if (item.type === "text" && item.text?.trim()) { this.emit(`USER: ${this.smartTrunc(item.text, "user-msg")}`); @@ -389,25 +388,16 @@ export class SessionStreamer { } } - private startSubagentLog(tailer: JsonlTailer, label: string): void { + private startSubagentLog(tailer: JsonlTailer, _label: string): void { if (!existsSync(this.denoiseScript)) return; try { const proc = spawn(this.denoiseScript, ["cat"], { stdio: ["pipe", "pipe", "inherit"], - env: { ...process.env, DENOISE: "1", DENOISE_DISPLAY_NAME: label, root: this.opts.repoDir }, + env: { ...process.env, DENOISE: "1", DENOISE_DISPLAY_NAME: _label, root: this.opts.repoDir }, }); - let urlExtracted = false; proc.stdout?.on("data", (chunk: Buffer) => { - const text = chunk.toString(); - if (!urlExtracted) { - const urlMatch = text.match(/(https?:\/\/ci\.aztec-labs\.com\/[a-f0-9-]+)/); - if (urlMatch) { - urlExtracted = true; - this.writeActivity("agent_log", `${label} ${urlMatch[1]}`); - } - } - // Forward to main cache log - this.opts.onOutput?.(text); + // Forward subagent output to main cache log + this.opts.onOutput?.(chunk.toString()); }); tailer.subLogProc = proc; } catch {} diff --git a/packages/libclaudebox/util.ts b/packages/libclaudebox/util.ts index f32a936..1b86cdb 100644 --- a/packages/libclaudebox/util.ts +++ b/packages/libclaudebox/util.ts @@ -7,7 +7,11 @@ export function truncate(s: string, n = 80): string { } export function extractHashFromUrl(text: string): string | null { - // Match log URLs: /- or legacy /<32hex> + // Match session page URLs: /s/ + const hostEscaped = CLAUDEBOX_HOST.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pageM = text.match(new RegExp(`^?`)); + if (pageM) return pageM[1]; + // Match legacy log URLs: / const escaped = LOG_BASE_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/^https?/, "https?"); const m = text.match(new RegExp(`^?`)); return m ? m[1] : null; @@ -92,6 +96,27 @@ export function sessionUrl(worktreeId: string): string { return `https://${CLAUDEBOX_HOST}/s/${worktreeId}`; } +/** Extract worktree ID from a log URL. Handles session page URLs, legacy logId URLs, and raw logId strings. */ +export function worktreeIdFromLogUrl(logUrl: string): string { + // Session page format: /s/ + const m0 = logUrl.match(/\/s\/([a-f0-9]{16})(?:\?|$|#)/); + if (m0) return m0[1]; + // LogId format: - + const m1 = logUrl.match(/\/([a-f0-9]{16})-\d+$/); + if (m1) return m1[1]; + // Legacy: <32hex> — no worktree ID embedded + return ""; +} + +/** Extract the full log ID from a log URL. */ +export function hashFromLogUrl(logUrl: string): string { + // Session page format: /s/ + const m0 = logUrl.match(/\/s\/([a-f0-9]{16})(?:\?|$|#)/); + if (m0) return m0[1]; + const m = logUrl.match(/\/([a-f0-9][\w-]+)$/); + return m ? m[1] : ""; +} + /** Extract a PR binding key ("owner/repo#123") from a GitHub PR URL. Returns null if not a PR link. */ export function prKeyFromUrl(url: string): string | null { const m = url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/); diff --git a/packages/libcreds/github.ts b/packages/libcreds/github.ts index 671d890..dbc8d68 100644 --- a/packages/libcreds/github.ts +++ b/packages/libcreds/github.ts @@ -236,6 +236,17 @@ export class GitHubClient { }); } + async updateGist(gistId: string, opts: { + description?: string; + files: Record; + }): Promise { + audit("github", "write", `PATCH gists/${gistId}`, true); + return this.ghJson(`gists/${gistId}`, { + method: "PATCH", + body: { ...(opts.description ? { description: opts.description } : {}), files: opts.files }, + }); + } + async putContents(repo: string, path: string, opts: { message: string; content: string; branch?: string; sha?: string; }): Promise { diff --git a/profiles/barretenberg-audit/container-claude.md b/profiles/barretenberg-audit/container-claude.md index 3dd21c0..738f905 100644 --- a/profiles/barretenberg-audit/container-claude.md +++ b/profiles/barretenberg-audit/container-claude.md @@ -49,20 +49,22 @@ The skills load PRINCIPLES.md (known bug classes) and CRITERIA.md (code quality | `get_context` | Session metadata | | `session_status` | Update Slack + GitHub status in-place. Call frequently. | | `github_api` | GitHub REST API proxy — **read-only** (GET only) | +| `slack_api` | Slack API proxy | | `create_issue` | **Create GitHub issues for findings** — specify quality_dimension + severity | | `close_issue` | Close a GitHub issue — posts a tracking comment with session log before closing | | `add_labels` | Add labels to an existing issue or PR | | `create_audit_label` | Create a new audit scope label + commit its prompt file to the repo | | `add_log_link` | Post a cross-reference comment linking an issue to this session's log | -| `self_assess` | **REQUIRED** — rate your session + each quality dimension | | `create_pr` | Push changes and create a draft PR (for fixes) | | `update_pr` | Push to / modify existing PRs | | `create_external_pr` | Push changes and create a draft PR on **upstream** `AztecProtocol/barretenberg` (requires `create-external-pr` scope) | -| `create_gist` | Share verbose output | +| `read_log` | Read a CI log by key/hash. Use instead of curling ci.aztec-labs.com or CI_PASSWORD. | +| `write_log` | Write content to a CI log — lightweight alternative to create_gist for build output. | +| `create_gist` | Create a gist (one per session, then use update_gist) | +| `update_gist` | Add/update files in an existing gist | | `list_gists` | List all audit gists — review prior session summaries | | `read_gist` | Read full gist content by ID or URL | | `update_meta_issue` | Create/update a meta-issue tracking session or module audit progress | -| `create_skill` | **Create follow-up skills** — encode open questions, findings, and next steps for future sessions | | `ci_failures` | CI status for a PR | | `audit_history` | **Call early** — get prior audit coverage and where to focus | | `record_stat` | Record structured data (`audit_file_review` per file, `audit_summary` per session) | @@ -90,12 +92,10 @@ create_issue( 6. `record_stat` — record each file reviewed with `audit_file_review` schema 7. `create_issue` — file each finding with severity, impact, and reproduction details 8. `add_log_link` — cross-reference related issues to this session -9. `create_gist` — **create a summary gist** with detailed findings, coverage table, open questions +9. `create_gist` — create a summary gist (use `update_gist` if you need to add more files) 10. `record_stat` — record `audit_summary` with the gist URL 11. `update_meta_issue` — create session meta-issue linking all artifacts -12. `create_skill` — capture open questions and follow-up work as a skill -13. **Mandatory review** — see below -14. **`respond_to_user`** — final summary (REQUIRED, 1-2 sentences + gist link) +12. **`respond_to_user`** — final summary (REQUIRED, 1-2 sentences + gist link) ### Final response — `respond_to_user` (REQUIRED) @@ -104,18 +104,6 @@ Keep it to 1-2 SHORT sentences. Print verbose output to stdout and reference the - Good: "Reviewed polynomial commitment code. Filed 3 issues — 1 high severity (buffer overflow in evaluator), 2 medium. " - Good: "No critical findings in field arithmetic. 12 files reviewed line-by-line. " -### Open questions and follow-up skills - -After reviewing code, create a **skill** for follow-up work using `create_skill`. Skills encode your findings, open questions, and next steps so a future session can pick up where you left off. - -``` -create_skill( - name="audit-poly-commitment-followup", - description="Follow-up audit of polynomial commitment bounds and carry proofs", - content="## Context\n\nPrevious session reviewed polynomial commitment code in `barretenberg/cpp/src/barretenberg/commitment_schemes/`.\n\n## Open Questions\n\n1. Is the carry_lo_msb bound of 70 bits provably tight in `unsafe_evaluate_multiply_add`?\n2. Are Montgomery reduction bounds sufficient for the field overflow case?\n\n## What was reviewed\n- evaluator.cpp: line-by-line, filed issue #12 for buffer overflow\n- commitment.hpp: surface review only\n\n## Next steps\n- Deep review of commitment.hpp\n- Verify carry proof tightness with formal bounds\n- Check pairing precompile interaction" -) -``` - Use `audit-finding` label on `create_issue` for findings. ### Cross-referencing — `add_log_link` @@ -140,8 +128,6 @@ Every file review and issue is tagged with a **quality dimension**: - Pick ONE dimension per file review entry. If you reviewed both code and crypto aspects of a file, record TWO separate `record_stat` calls. - **`crypto-2nd-pass`** — ONLY use this if `audit_history` shows the file was already reviewed under `crypto` by a **different** session. This provides independent verification. Do NOT use it for your own re-reviews within the same session. - When creating issues, specify `quality_dimension` and `severity` — these are tracked for completion metrics. -- Your `self_assess` at the end should rate each dimension you covered. - ### Severity Calibration With AI-assisted development in 2026, development velocity is dramatically higher and maintenance burdens are far lower — calibrate "maintenance cost" severity accordingly. Focus severity on soundness, security, and correctness impact rather than code cleanliness. @@ -160,9 +146,9 @@ record_stat(schema="audit_file_review", data={ }) ``` -### Session summary gist — `create_gist` + `record_stat` +### Session summary gist -**Before finishing, create a summary gist.** This is the primary record of your work — the Slack response should be short, the gist should be thorough. The gist MUST contain these four sections: +Create a summary gist before finishing. Use `update_gist` to add files if needed. The gist MUST contain these four sections: 1. **Executive Summary** (2-4 lines) — What you reviewed, key findings, overall risk assessment. 2. **Skill Improvements** — What changes to Claude skills/prompts would help future audit sessions? Missing context, unhelpful instructions, tools that should exist, knowledge gaps. @@ -229,13 +215,7 @@ Before calling `respond_to_user`, you MUST: 3. **Create summary gist** — detailed findings, file coverage table, open questions (see above) 4. **`record_stat`** — record `audit_summary` with gist URL 5. **`update_meta_issue`** — create a session meta-issue linking all artifacts + executive summary + next recommendation -6. **Create follow-up skill** — capture open questions, partial progress, and next steps via `create_skill` -7. **`self_assess`** — honestly rate your session: - - `critical` = found security-relevant issues - - `thorough` = deep line-by-line review, no critical issues - - `surface` = quick scan, identified areas for deeper review - - `incomplete` = could not finish due to complexity or missing context -8. **`respond_to_user`** — final 1-2 sentence summary + link to gist +6. **`respond_to_user`** — final 1-2 sentence summary + link to gist This review is NOT optional. Skipping it means the audit trail is incomplete. @@ -251,7 +231,7 @@ This review is NOT optional. Skipping it means the audit trail is incomplete. ## Rules - Update status frequently via `session_status` -- End with `self_assess` then `respond_to_user` +- End with `respond_to_user` - **Never use `gh` CLI or `git push`** — use dedicated MCP tools. `github_api` is read-only. - **Git identity**: You are `AztecBot `. Do NOT add `Co-Authored-By` trailers. - File **one issue per finding** with clear severity ratings diff --git a/profiles/barretenberg-audit/mcp-sidecar.ts b/profiles/barretenberg-audit/mcp-sidecar.ts index dab254b..fb447e5 100755 --- a/profiles/barretenberg-audit/mcp-sidecar.ts +++ b/profiles/barretenberg-audit/mcp-sidecar.ts @@ -5,7 +5,7 @@ * Repo: AztecProtocol/barretenberg-claude (private fork) * Clone strategy: remote authenticated URL (no local reference) * Docker proxy: disabled - * Extra tools: create_issue, close_issue, create_audit_label, add_log_link, self_assess + * Extra tools: create_issue, close_issue, create_audit_label, add_log_link */ import { mkdirSync, appendFileSync, readFileSync, existsSync } from "fs"; @@ -17,7 +17,7 @@ import { SESSION_META, WORKTREE_ID, statusPageUrl, STATS_DIR, hasScope } from ". import { logActivity, updateRootComment, otherArtifacts } from "../../packages/libclaudebox/mcp/activity.ts"; import { getCreds, sanitizeError } from "../../packages/libclaudebox/mcp/helpers.ts"; import { registerCommonTools } from "../../packages/libclaudebox/mcp/tools.ts"; -import { pushToRemote, registerCloneRepo, registerPRTools } from "../../packages/libclaudebox/mcp/git-tools.ts"; +import { pushToRemote, registerCloneRepo, registerPRTools, registerGitProxy, registerLogTools } from "../../packages/libclaudebox/mcp/git-tools.ts"; import { startMcpHttpServer } from "../../packages/libclaudebox/mcp/server.ts"; // ── Profile config ────────────────────────────────────────────── @@ -28,7 +28,7 @@ SESSION_META.repo = REPO; const UPSTREAM_REPO = "AztecProtocol/barretenberg"; -const TOOL_LIST = "clone_repo, respond_to_user, get_context, session_status, github_api, create_pr, update_pr, create_external_pr, create_issue, close_issue, add_labels, create_audit_label, add_log_link, self_assess, audit_history, create_gist, list_gists, read_gist, update_meta_issue, create_skill, ci_failures, linear_get_issue, linear_create_issue, record_stat"; +const TOOL_LIST = "clone_repo, respond_to_user, get_context, session_status, github_api, create_pr, update_pr, create_external_pr, create_issue, close_issue, add_labels, create_audit_label, add_log_link, audit_history, create_gist, update_gist, list_gists, read_gist, update_meta_issue, ci_failures, linear_get_issue, linear_create_issue, record_stat, git_fetch, git_pull, submodule_update, read_log, write_log"; // ── Auth check at startup ─────────────────────────────────────── try { @@ -53,7 +53,6 @@ function createServer(): McpServer { fallbackRef: "origin/master", refHint: "'origin/main', 'abc123'", description: "Clone the barretenberg-claude repo (private). Uses authenticated URL. Safe to call on resume — fetches new refs. Call FIRST before doing any work.", - skipSubmodules: true, }); registerPRTools(server, { @@ -260,54 +259,6 @@ Use this when you discover a new area worth dedicated audit attention.`, } }); - // ── self_assess — session self-assessment ──────────────────────── - server.tool("self_assess", - `Rate your own audit session. Call this BEFORE respond_to_user. -Be honest about your assessment: -- critical = found security-relevant issues -- thorough = deep line-by-line review, no critical issues found -- surface = quick scan, identified areas for deeper review -- incomplete = could not finish due to complexity or missing context - -Also rate each quality dimension you covered: -- code = implementation correctness (UB, memory safety, API design) -- crypto = cryptographic correctness (math, protocol security, side-channels) -- test = test adequacy (coverage, edge cases, assertions)`, - { - rating: z.enum(["critical", "thorough", "surface", "incomplete"]).describe("Self-assessment rating"), - modules_reviewed: z.array(z.string()).describe("Source paths reviewed, e.g. ['barretenberg/cpp/src/barretenberg/ecc/curves']"), - findings_count: z.number().describe("Number of issues filed this session"), - questions_count: z.number().describe("Number of question issues posted this session"), - confidence: z.number().min(0).max(1).describe("Confidence in the review (0 = guessing, 1 = certain)"), - summary: z.string().describe("2-3 sentence summary of what was covered and key findings"), - code_rating: z.enum(["thorough", "surface", "none"]).default("none").describe("Code quality review depth"), - crypto_rating: z.enum(["thorough", "surface", "none"]).default("none").describe("Crypto quality review depth"), - test_rating: z.enum(["thorough", "surface", "none"]).default("none").describe("Test quality review depth"), - }, - async ({ rating, modules_reviewed, findings_count, questions_count, confidence, summary, code_rating, crypto_rating, test_rating }) => { - try { - const entry = { - _ts: new Date().toISOString(), - _log_id: SESSION_META.log_id, - _worktree_id: WORKTREE_ID, - _user: SESSION_META.user, - rating, modules_reviewed, findings_count, questions_count, confidence, summary, - code_rating, crypto_rating, test_rating, - }; - - mkdirSync(STATS_DIR, { recursive: true }); - appendFileSync(join(STATS_DIR, "audit_assessment.jsonl"), JSON.stringify(entry) + "\n"); - - const dims = [code_rating !== "none" ? `code:${code_rating}` : "", crypto_rating !== "none" ? `crypto:${crypto_rating}` : "", test_rating !== "none" ? `test:${test_rating}` : ""].filter(Boolean).join(", "); - logActivity("status", `Assessment: ${rating.toUpperCase()} (${Math.round(confidence * 100)}% confidence) [${dims}] — ${summary}`); - await updateRootComment(); - - return { content: [{ type: "text", text: `Assessment recorded: ${rating} (${modules_reviewed.length} modules, ${findings_count} findings, ${questions_count} questions) [${dims}]` }] }; - } catch (e: any) { - return { content: [{ type: "text", text: `self_assess: ${sanitizeError(e.message)}` }], isError: true }; - } - }); - // ── audit_history — read prior audit stats for continuity ─────── server.tool("audit_history", `Get a summary of prior audit work: module coverage by quality dimension, recent sessions, and artifacts. @@ -559,6 +510,9 @@ Use this for fixes that should go directly to the main barretenberg repo.`, } }); + registerGitProxy(server, { workspace: WORKSPACE }); + registerLogTools(server, { workspace: WORKSPACE }); + return server; } diff --git a/profiles/claudebox-dev/container-claude.md b/profiles/claudebox-dev/container-claude.md index 120e85c..d2345b1 100644 --- a/profiles/claudebox-dev/container-claude.md +++ b/profiles/claudebox-dev/container-claude.md @@ -42,7 +42,8 @@ Key directories: | `create_pr` | Push changes and create a draft PR targeting `main` | | `update_pr` | Push to / modify existing PRs | | `push_branch` | Push directly to `main` without creating a PR | -| `create_gist` | Share verbose output | +| `create_gist` | Create a gist (one per session, then use update_gist) | +| `update_gist` | Add/update files in an existing gist | | `ci_failures` | CI status for a PR | | `linear_get_issue` | Fetch a Linear issue | | `linear_create_issue` | Create a Linear issue | diff --git a/profiles/claudebox-dev/mcp-sidecar.ts b/profiles/claudebox-dev/mcp-sidecar.ts index 36af939..7e755d1 100755 --- a/profiles/claudebox-dev/mcp-sidecar.ts +++ b/profiles/claudebox-dev/mcp-sidecar.ts @@ -17,7 +17,7 @@ import { SESSION_META } from "../../packages/libclaudebox/mcp/env.ts"; import { logActivity } from "../../packages/libclaudebox/mcp/activity.ts"; import { getCreds, git, sanitizeError } from "../../packages/libclaudebox/mcp/helpers.ts"; import { registerCommonTools } from "../../packages/libclaudebox/mcp/tools.ts"; -import { pushToRemote, registerCloneRepo, registerPRTools } from "../../packages/libclaudebox/mcp/git-tools.ts"; +import { pushToRemote, registerCloneRepo, registerPRTools, registerGitProxy } from "../../packages/libclaudebox/mcp/git-tools.ts"; import { startMcpHttpServer } from "../../packages/libclaudebox/mcp/server.ts"; // ── Profile config ────────────────────────────────────────────── @@ -27,7 +27,7 @@ const DEV_BRANCH = "main"; SESSION_META.repo = REPO; -const TOOL_LIST = "clone_repo, respond_to_user, get_context, session_status, github_api, create_pr, update_pr, push_branch, create_gist, create_skill, ci_failures, linear_get_issue, linear_create_issue, record_stat"; +const TOOL_LIST = "clone_repo, respond_to_user, get_context, session_status, github_api, create_pr, update_pr, push_branch, create_gist, update_gist, ci_failures, linear_get_issue, linear_create_issue, record_stat, git_fetch, git_pull, submodule_update"; // ── MCP Server factory ────────────────────────────────────────── @@ -84,6 +84,8 @@ function createServer(): McpServer { } }); + registerGitProxy(server, { workspace: WORKSPACE }); + return server; } diff --git a/profiles/default/container-claude.md b/profiles/default/container-claude.md index b0077bd..52fc423 100644 --- a/profiles/default/container-claude.md +++ b/profiles/default/container-claude.md @@ -3,37 +3,51 @@ You have no interactive user — work autonomously. ## Environment -- **Working directory**: `/workspace` — on fresh sessions it's empty; on resume sessions the repo may already exist -- Use the `clone_repo` MCP tool to set up or update the repo. It's safe to call on resume — it fetches, checks out, and updates submodules. -- Pass the ref from your prompt's `Target ref:` line (e.g. `origin/next`). This is the git ref to **checkout** — distinct from `Base branch` which is the PR target. -- After cloning, the repo is at `/workspace/aztec-packages`. All work happens there. -- Remote: `https://github.com/AztecProtocol/aztec-packages.git` (public, full `git fetch` works) +- **Working directory**: `/workspace/aztec-packages` — the repo is **pre-cloned** from `origin/next` (or the base branch) at container start. You are already inside it. +- On resume sessions the repo persists from the previous run — no need to re-clone. +- Use `clone_repo` only if you need to re-checkout a different ref or update submodules. It's safe to call repeatedly. +- Remote: `https://github.com/AztecProtocol/aztec-packages.git` - Full internet access for packages, builds, etc. - Use `/tmp` for scratch files +## Git Authentication + +**IMPORTANT**: The container has NO direct git credentials. `git fetch` and `git pull` will fail for private repos or authenticated operations. + +Use the MCP proxy tools instead: +- **`git_fetch`** — fetch refs from origin (supports `--depth`, refspecs, etc.) +- **`git_pull`** — pull from origin (supports `--rebase`, `--ff-only`, etc.) +- **`submodule_update`** — initialize and update submodules (optionally to a specific commit) + +These tools handle authentication through the sidecar. Use them instead of bare `git fetch`/`git pull`. + +**Submodules are NOT initialized by default.** If your task requires submodules (e.g. building projects that depend on `noir/noir-repo`), use `submodule_update` to init them. + +For public repos, bare `git fetch` works but prefer the MCP tools for consistency. + ## Checking out other branches - **PR review/fix** (e.g. `#12345`): - ```bash - git fetch origin pull/12345/head:pr-12345 + ``` + git_fetch(args="origin pull/12345/head:pr-12345") git checkout pr-12345 ``` - **Branch work**: - ```bash - git fetch origin + ``` + git_fetch(args="origin ") git checkout origin/ ``` ## CI Logs -Download and view CI logs using `dlog` via `ci.sh` (in the repo root, NOT in `ci3/`): -```bash -/workspace/aztec-packages/ci.sh dlog # view a log by hash -/workspace/aztec-packages/ci.sh dlog | head -100 # first 100 lines -/workspace/aztec-packages/ci.sh dlog > /tmp/log.txt # save to file for analysis -``` -URLs like `http://ci.aztec-labs.com/` — extract the hash and use `dlog`. -Prefer `dlog` over curling ci.aztec-labs.com directly — it's faster and handles auth. +**IMPORTANT**: Do NOT use `CI_PASSWORD`, curl `ci.aztec-labs.com` directly, or run `ci.sh dlog` manually. Use the MCP tools instead: + +- **`read_log(key="")`** — read a CI log by key. Supports `head`/`tail` params for large logs. +- **`write_log(content="...", key="my-key")`** — write content to a CI log. Returns a shareable URL. + +For CI log URLs like `http://ci.aztec-labs.com/`, extract the hash and pass it to `read_log`. + +`write_log` is a lightweight alternative to `create_gist` for build output, command logs, and quick shareable content. ## Communication — MCP Tools @@ -43,16 +57,22 @@ Do NOT use `gh api`, `gh pr`, `gh` commands, or `git push` — they will all fai | Tool | Purpose | |------|---------| -| `clone_repo` | **FIRST** — clone/update the repo at a given ref. Safe on resume. | +| `clone_repo` | Clone/update the repo at a given ref. Safe on resume. Usually not needed — repo is pre-cloned. | +| `git_fetch` | Fetch refs from origin (authenticated). Use instead of bare `git fetch`. | +| `git_pull` | Pull from origin (authenticated). Use instead of bare `git pull`. | +| `submodule_update` | Init/update submodules recursively, optionally to a specific commit. | | `set_workspace_name` | Call right after cloning — give this workspace a short descriptive slug. | | `respond_to_user` | **REQUIRED** — send your final response (Slack + GitHub). | | `get_context` | Session metadata (user, repo, log_url, thread, etc.) | | `session_status` | Update Slack + GitHub status message in-place. Call frequently. | | `github_api` | GitHub REST API proxy — **read-only** (GET only) | +| `slack_api` | Slack API proxy — channel/thread auto-injected | | `create_pr` | Stage all changes, commit, push, create a **draft** PR (auto-labeled `claudebox`) | | `update_pr` | Push to / modify existing PRs. Only `claudebox`-labeled PRs. | -| `create_gist` | Create a GitHub gist — useful for sharing verbose output | -| `create_skill` | Create a reusable skill (/) and open a PR for review | +| `read_log` | Read a CI log by key/hash. Use instead of curling ci.aztec-labs.com or CI_PASSWORD. | +| `write_log` | Write content to a CI log — lightweight alternative to create_gist for build output. | +| `create_gist` | Create a gist (one per session, then use update_gist) | +| `update_gist` | Add/update files in an existing gist | | `ci_failures` | CI status for a PR — failed jobs, pass/fail history, links | | `linear_get_issue` | Fetch a Linear issue by identifier (e.g. `A-453`) | | `linear_create_issue` | Create a new Linear issue | @@ -98,7 +118,7 @@ update_pr(pr_number=12345, push=true, title="updated title") ``` ### Workflow: -1. `clone_repo` — pass the `Target ref` from your prompt (e.g. `origin/next`) +1. The repo is pre-cloned at `/workspace/aztec-packages`. If you need a different ref, use `clone_repo`. 2. `set_workspace_name` — give this workspace a short slug (e.g. "fix-flaky-p2p-test") 3. `get_context` — get session metadata (log_url, base_branch, etc.) 4. `session_status` — report progress frequently (edits the status message in-place) @@ -127,8 +147,8 @@ Your prompt contains two key values: These are often related but different. For example, when fixing a PR, the target ref might be the PR branch while the base branch is `next`. **Rebasing onto the correct base**: If your target ref differs from your base branch (e.g., you cloned from `origin/next` but need to PR against `backport-to-v4-staging`), you **must** rebase your commits onto the actual base branch before pushing: -```bash -git fetch origin +``` +git_fetch(args="origin ") git rebase --onto origin/ HEAD ``` This ensures your commits apply cleanly to the PR target. Without this, the PR diff will include unrelated commits from the wrong base. @@ -158,35 +178,40 @@ cd /workspace/aztec-packages/barretenberg/cpp && ./bootstrap.sh The container has all required toolchains (Rust, Node, etc.). -### Build logs — `cache_log` +### Build logs -**ALWAYS** pipe long-running commands (`./bootstrap.sh`, `make`, test suites, cargo builds) through `cache_log` so a persistent log link is created. This lets users see build output even after the session ends. +For long-running commands (`./bootstrap.sh`, `make`, test suites), capture the output and use `write_log` to create a persistent shareable link: ```bash -# From the repo root — pipe through cache_log with DUP=1 to also show output in real-time -./bootstrap.sh 2>&1 | DUP=1 ci3/cache_log "yarn-project-bootstrap" -make yarn-project 2>&1 | DUP=1 ci3/cache_log "make-yarn-project" -cd barretenberg/cpp && cmake --build build 2>&1 | DUP=1 ci3/cache_log "bb-cpp-build" +# Run build, capture output +make yarn-project 2>&1 | tee /tmp/build.log +# Share via write_log MCP tool +write_log(content=, key="make-yarn-project") ``` -The log URL is printed to stderr: `http://ci.aztec-labs.com/`. After the command finishes, **report the log URL** via `session_status` so users can access it. +Or pipe through `cache_log` directly for real-time streaming: +```bash +./bootstrap.sh 2>&1 | DUP=1 ci3/cache_log "yarn-project-bootstrap" +``` -If the command fails, the log link still persists — making it easy to diagnose failures after the fact. +After the command finishes, **report status** via `session_status` so users can track progress. ## Tips — avoiding common failures - **Absolute paths**: Always use absolute paths (e.g. `/workspace/aztec-packages/...`) with `Read`, `Glob`, `Grep`. Relative paths will fail if your cwd changed. - **Large files**: If `Read` fails with "exceeds maximum", use `offset`+`limit` to read chunks, or `Grep` to find what you need. - **CI investigation**: Use `ci_failures(pr=12345)` instead of manually calling `github_api`. +- **CI logs**: Use `read_log(key="")` to read logs. **Never** use `CI_PASSWORD`, curl `ci.aztec-labs.com`, or `ci.sh dlog` directly. - **JSON parsing**: Use `jq` — it handles large/truncated input gracefully. - **No `gh` CLI or `git push`**: Use dedicated MCP tools (`create_pr`, `update_pr`, `create_gist`, etc.). `github_api` is read-only. -- **Git conflicts on resume**: If `git fetch` fails with "untracked files would be overwritten", run `git checkout . && git clean -fd` first. +- **No direct `git fetch`/`git pull`**: Use the `git_fetch` and `git_pull` MCP tools — they handle authentication. +- **Git conflicts on resume**: If `git_fetch` fails with "untracked files would be overwritten", run `git checkout . && git clean -fd` first. - **Always use full GitHub URLs**: `https://github.com/AztecProtocol/aztec-packages/pull/123` not `PR #123`. - **`session_status` edits in place**: It updates the existing Slack/GitHub status message. Call it often — it won't create noise. ## Rules - Update status frequently via `session_status` - End with `respond_to_user` (the user won't see your final text message without it) -- **Never use `gh` CLI or `git push`** — use MCP tools instead -- Public read-only access (`curl` to public URLs, `git fetch`) works directly +- **Never use `gh` CLI, `git push`, or bare `git fetch`/`git pull`** — use MCP tools instead +- Public read-only access (`curl` to public URLs) works directly - **Git identity**: You are `AztecBot `. Do NOT add `Co-Authored-By` trailers. diff --git a/profiles/default/mcp-sidecar.ts b/profiles/default/mcp-sidecar.ts index da8436b..78e99f6 100755 --- a/profiles/default/mcp-sidecar.ts +++ b/profiles/default/mcp-sidecar.ts @@ -8,14 +8,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerCommonTools } from "../../packages/libclaudebox/mcp/tools.ts"; -import { registerCloneRepo, registerPRTools } from "../../packages/libclaudebox/mcp/git-tools.ts"; +import { registerCloneRepo, registerPRTools, registerGitProxy, registerLogTools } from "../../packages/libclaudebox/mcp/git-tools.ts"; import { startMcpHttpServer } from "../../packages/libclaudebox/mcp/server.ts"; // ── Profile config ────────────────────────────────────────────── const REPO = "AztecProtocol/aztec-packages"; const WORKSPACE = process.env.WORKSPACE || "/workspace/aztec-packages"; -const TOOL_LIST = "clone_repo, respond_to_user, get_context, session_status, github_api, create_pr, update_pr, create_gist, create_skill, ci_failures, linear_get_issue, linear_create_issue, record_stat"; +const TOOL_LIST = "clone_repo, respond_to_user, get_context, session_status, github_api, create_pr, update_pr, create_gist, update_gist, ci_failures, linear_get_issue, linear_create_issue, record_stat, git_fetch, git_pull, submodule_update, read_log, write_log"; // ── MCP Server factory ────────────────────────────────────────── @@ -41,6 +41,9 @@ function createServer(): McpServer { updateDescription: "Push workspace commits and/or update an existing PR. Only works on PRs with the 'claudebox' label.", }); + registerGitProxy(server, { workspace: WORKSPACE }); + registerLogTools(server, { workspace: WORKSPACE }); + return server; } diff --git a/profiles/test/mcp-sidecar.ts b/profiles/test/mcp-sidecar.ts index f827f8f..9d67e03 100755 --- a/profiles/test/mcp-sidecar.ts +++ b/profiles/test/mcp-sidecar.ts @@ -18,7 +18,7 @@ import { startMcpHttpServer } from "../../packages/libclaudebox/mcp/server.ts"; const REPO = process.env.CLAUDEBOX_TEST_REPO || "ludamad/test-mfh"; const WORKSPACE = process.env.WORKSPACE || "/workspace/test-mfh"; -const TOOL_LIST = "clone_repo, respond_to_user, get_context, session_status, set_workspace_name, github_api, create_pr, update_pr, create_gist, create_skill, ci_failures, linear_get_issue, linear_create_issue, record_stat"; +const TOOL_LIST = "clone_repo, respond_to_user, get_context, session_status, set_workspace_name, github_api, create_pr, update_pr, create_gist, update_gist, create_skill, ci_failures, linear_get_issue, linear_create_issue, record_stat"; // ── MCP Server factory ────────────────────────────────────────── diff --git a/tests/setup.ts b/tests/setup.ts index c94cd46..a161fae 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -2,7 +2,6 @@ // Node test runner's --import flag ensures this runs first. process.env.CLAUDEBOX_SESSION_PASS = "test-pass"; -process.env.CLAUDEBOX_LOG_BASE_URL = "http://ci.example.com"; process.env.CLAUDEBOX_HOST = "claudebox.test"; process.env.CLAUDEBOX_DEFAULT_BRANCH = "main"; process.env.CLAUDEBOX_SESSION_USER = "testadmin";